Battle Prime é o primeiro projeto do nosso estúdio. Apesar do fato de muitos membros da equipe terem uma experiência decente no desenvolvimento de jogos, naturalmente enfrentamos várias dificuldades ao trabalhar nele. Eles surgiram tanto no processo de trabalhar no mecanismo quanto no desenvolvimento do próprio jogo.
No setor de gamedev, um grande número de desenvolvedores que compartilham de bom grado suas histórias, melhores práticas, decisões de arquitetura - de uma forma ou de outra. Essa experiência, apresentada no espaço público na forma de artigos, apresentações e relatórios, é uma excelente fonte de idéias e inspiração. Por exemplo, os relatórios da equipe de desenvolvimento de Overwatch foram muito úteis para nós quando trabalhamos no mecanismo. Como o jogo em si, eles são feitos com muito talento, e eu aconselho todos os interessados em vê-los. Disponível no cofre da GDC e no
YouTube .
Essa é uma das razões pelas quais também queremos contribuir para a causa comum - e este artigo é um dos primeiros dedicados aos detalhes técnicos do desenvolvimento e da execução do Blitz Engine - Battle Prime.
O artigo será dividido em duas partes:
- ECS: implementação do padrão Entity-Component-System dentro do Blitz Engine. Esta seção é importante para entender os exemplos de código no artigo e, por si só, é um tópico interessante separado.
- Código de rede e jogabilidade: tudo sobre a parte de alto nível da rede e seu uso dentro do jogo - arquitetura cliente-servidor, previsões de clientes, replicação. Uma das coisas mais importantes em um jogo de tiro é atirar, para que mais tempo seja dedicado a ele.
Sob o corte de muitos megabytes de gifs!Dentro de cada seção, além da história sobre a funcionalidade e seu uso, tentarei descrever as deficiências que ela traz consigo - sejam suas limitações, inconveniência no trabalho ou apenas pensamentos sobre suas melhorias no futuro.
Também tentarei dar exemplos de código e algumas estatísticas. Em primeiro lugar, é apenas interessante e, em segundo lugar, fornece um pouco de contexto na escala do uso dessa ou daquela funcionalidade e projeto.
ECS
Dentro do mecanismo, usamos o termo "mundo" para descrever uma cena que contém uma hierarquia de objetos.
Os mundos funcionam de acordo com o modelo do sistema de componentes de entidade (
descrição na Wikipedia ):
- Entidade - um objeto dentro da cena. É um repositório para um conjunto de componentes. Objetos podem ser aninhados, formando uma hierarquia no mundo;
- Componente - são os dados necessários para a operação de qualquer mecânica e que determina o comportamento do objeto. Por exemplo, `TransformComponent` contém a transformação do objeto e` DynamicBodyComponent` contém dados para simulação física. Alguns componentes podem não ter dados adicionais, sua simples presença no objeto descreve o estado desse objeto. Por exemplo, no Battle Prime, são usados `AliveComponent` e` DeadComponent`, que marcam caracteres ativos e inativos, respectivamente;
- Sistema - um conjunto de funções chamado periodicamente que suporta a solução de sua tarefa. Com cada chamada, o sistema processa objetos que satisfazem alguma condição (geralmente com um determinado conjunto de componentes) e, se necessário, os modifica. Toda a lógica do jogo e a maior parte do mecanismo são implementadas no nível do sistema. Por exemplo, dentro do mecanismo, existe um `LodSystem`, que se dedica ao cálculo dos índices LOD (nível de detalhe) de um objeto com base em sua transformação no mundo e em outros dados. Este índice contido no `LodComponent` é então usado por outros sistemas para suas tarefas.
Essa abordagem facilita a combinação de diferentes mecânicas no mesmo objeto. Assim que a entidade recebe dados suficientes para o trabalho de algumas mecânicas, os sistemas responsáveis por essa mecânica começam a processar esse objeto.
Na prática, adicionar um novo funcional reduz-se a um novo componente (ou conjunto de componentes) e a um novo sistema (ou conjunto de sistemas) que implementa esse funcional. Na grande maioria dos casos, é conveniente trabalhar nesse padrão.
Reflexão
Antes de prosseguir com a descrição dos componentes e sistemas, vou me debruçar um pouco sobre o mecanismo de reflexão, pois ele geralmente será usado em exemplos de código.
O Reflection permite receber e usar informações sobre tipos enquanto o aplicativo está em execução. Em particular, os seguintes recursos estão disponíveis:
- Obtenha uma lista de tipos de acordo com um critério específico (por exemplo, os herdeiros de uma classe ou com uma tag especial),
- Obtenha uma lista de campos de classe,
- Obtenha uma lista de métodos dentro da classe,
- Obtenha uma lista de valores de enumeração,
- Chame algum método ou altere o valor de um campo,
- Obtenha os metadados de um campo ou método que podem ser usados para uma funcionalidade específica.
Muitos módulos dentro do mecanismo usam a reflexão para seus próprios propósitos. Alguns exemplos:
- As integrações de linguagens de script usam reflexão para trabalhar com tipos declarados no código C ++;
- O editor usa reflexão para obter uma lista de componentes que podem ser adicionados ao objeto, além de exibir e editar seus campos;
- O módulo de rede usa os metadados do campo dentro dos componentes para várias funções: eles indicam os parâmetros para replicar campos do servidor para os clientes, quantizar dados durante a replicação e assim por diante;
- Várias configurações são desserializadas em objetos dos tipos correspondentes usando reflexão.
Usamos nossa própria implementação, cuja interface não é muito diferente de outras soluções existentes (por exemplo,
github.com/rttrorg/rttr ). Usando o exemplo de CapturePointComponent (que descreve o ponto de captura para o modo de jogo), adicionar reflexão ao tipo fica assim:
Gostaria de prestar atenção especial aos metadados de tipos, campos e métodos declarados usando a expressão
M<T>()
onde `T` é o tipo de metadados (dentro do comando, apenas usamos o termo" meta ", no futuro eu o usarei). Eles são usados por diferentes módulos para seus próprios propósitos. Por exemplo, o editor usa `DisplayName` para exibir os nomes e os campos do tipo dentro do editor, e o módulo de rede recebe uma lista de todos os componentes e, entre eles, procura por campos marcados como 'Replicável' - eles serão enviados do servidor para os clientes.
Descrição dos componentes e sua adição ao objeto
Cada componente é um herdeiro da classe base `Component 'e pode descrever com a ajuda da reflexão os campos que usa (se necessário).
É assim que o `AvatarHitComponent` é declarado e descrito dentro do jogo:
class AvatarHitComponent final : public Component { BZ_VIRTUAL_REFLECTION(Component); public: PlayerId source_id = NetConstants::INVALID_PLAYER_ID; PlayerId target_id = NetConstants::INVALID_PLAYER_ID; HitboxType hitbox_type = HitboxType::UNKNOWN; }; BZ_VIRTUAL_REFLECTION_IMPL(AvatarHitComponent) { ReflectionRegistrar::begin_class<AvatarHitComponent>() .ctor_by_pointer() .copy_ctor_by_pointer() .field("source_id", &AvatarHitComponent::source_id)[M<Replicable>()] .field("target_id", &AvatarHitComponent::target_id)[M<Replicable>()] .field("hitbox_type", &AvatarHitComponent::hitbox_type)[M<Replicable>()]; }
Este componente marca um objeto que é criado como resultado de um jogador atingir outro jogador. Ele contém informações sobre este evento, como os identificadores do jogador atacante e seu objetivo, bem como o tipo de caixa de acerto na qual o acerto ocorreu.
Simplificando, esse objeto é criado dentro do sistema do servidor de maneira semelhante:
Entity hit_entity = world->create_entity(); auto* const avatar_hit_component = hit_entity.add<AvatarHitComponent>(); avatar_hit_component->source_id = source_player_id; avatar_hit_component->target_id = target_player_id; avatar_hit_component->hitbox_type = hitbox_type;
O objeto com o `AvatarHitComponent` é então usado por diferentes sistemas: para tocar sons de jogadores, coletando estatísticas, rastreando as realizações dos jogadores e assim por diante.
Descrição dos sistemas e seu trabalho
Um sistema é um objeto com um tipo herdado do `System`, que contém métodos que implementam uma tarefa específica. Como regra, um método é suficiente. Vários métodos são necessários se eles devem ser executados em diferentes momentos no mesmo quadro.
Semelhante aos componentes que descrevem seus campos, cada sistema descreve os métodos que devem ser executados pelo mundo.
Por exemplo, o Sistema Explosivo responsável pelas explosões é declarado e descrito da seguinte forma:
Os seguintes dados são indicados na descrição do sistema:
- A tag à qual o sistema pertence. Cada mundo contém um conjunto de tags, e neles estão os sistemas que devem funcionar neste mundo. Nesse caso, a tag `battle` significa o mundo em que a batalha entre os jogadores ocorre. Outros exemplos de tags são `server` e` client` (o sistema roda apenas no servidor ou cliente, respectivamente) e` render` (o sistema roda apenas no modo GUI);
- O grupo no qual este sistema é executado e a lista de componentes que esse sistema utiliza - para escrever, ler e criar;
- Tipo de atualização - se este sistema deve funcionar em atualização normal, atualização fixa ou outros;
- Dependências de permissão explícitas entre sistemas.
Mais informações sobre grupos de sistemas, dependências e tipos de atualização serão descritas abaixo.
Os métodos declarados são chamados pelo mundo no momento certo para manter a funcionalidade deste sistema. O conteúdo do método depende do sistema, mas, como regra, é um passeio por todos os objetos que atendem aos critérios desse sistema e sua atualização subsequente. Por exemplo, a atualização do `ExplosiveSystem` dentro do jogo é a seguinte:
void ExplosiveSystem::update(float dt) { const auto* time_single_component = world->get<TimeSingleComponent>();
Os grupos no exemplo acima (`new_explosives_group` e` explosives_group`) são contêineres auxiliares que simplificam a implementação do sistema. new_explosives_group é um contêiner com novos objetos necessários para este sistema e que nunca foram processados, e explosives_group é um contêiner com todos os objetos que precisam ser processados em todos os quadros. O mundo é diretamente responsável por encher esses recipientes. Seu recebimento pelo sistema ocorre em seu construtor:
ExplosiveSystem::ExplosiveSystem(World* world) : System(world) {
Atualização mundial
O mundo, um objeto do tipo `Mundo ', cada quadro chama os métodos necessários em vários sistemas. Quais sistemas serão chamados depende do seu tipo.
Parte dos sistemas em que todos os quadros são necessariamente atualizados (o termo “atualização normal” é usado dentro do mecanismo) - esse tipo inclui todos os sistemas que afetam a renderização do quadro e dos sons: animações esqueléticas, partículas, interface do usuário e assim por diante. A outra parte é executada em uma frequência fixa e predeterminada (usamos o termo "atualização fixa" e para o número de atualizações fixas por segundo - FFPS) - elas processam a maior parte da lógica de jogo e tudo o que precisa ser sincronizado entre o cliente e o servidor - por exemplo, parte da entrada do jogador, movimento do personagem, tiro, parte da simulação física.

A frequência de execução de uma atualização fixa deve ser equilibrada - um valor muito pequeno leva a uma jogabilidade sem resposta (por exemplo, a entrada do jogador é processada com menos frequência e, portanto, com um atraso maior) e muito alta - a requisitos de desempenho elevados do dispositivo no qual o aplicativo está sendo executado. Isso também significa que quanto maior a frequência, maior o custo da capacidade do servidor (menos batalhas podem funcionar simultaneamente na mesma máquina).
No gif abaixo, o mundo trabalha com uma frequência de 5 atualizações fixas por segundo. Você pode notar o atraso entre pressionar o botão W e o início do movimento, bem como o atraso entre soltar o botão e interromper o movimento do personagem:
No próximo gif, o mundo trabalha com uma frequência de 30 atualizações fixas por segundo, o que fornece um controle significativamente mais responsivo:
No momento, na atualização fixa do Battle Prime, o mundo corre 31 vezes por segundo. Esse valor "feio" foi escolhido especialmente - ele pode causar erros que não existiriam em outras situações quando o número de atualizações por segundo for, por exemplo, um número redondo ou um múltiplo da taxa de atualização da tela.
Ordem de execução do sistema
Uma das coisas que complica o trabalho com o ECS é a tarefa de executar sistemas. Por contexto, no momento da redação deste artigo, no cliente Battle Prime durante a batalha entre os jogadores, existe um sistema 251 e seu número só está aumentando.
Um sistema que é executado por engano no momento errado pode levar a erros sutis ou a um atraso na operação de algumas mecânicas por quadro (por exemplo, se o sistema de danos funcionar no início do quadro e o sistema de vôo do projétil no final, o dano será causado. com um atraso de um quadro).
A ordem de execução dos sistemas pode ser definida de várias maneiras, por exemplo:
- Pedido explícito
- Indicação da “prioridade” numérica do sistema e posterior classificação por prioridade;
- Crie automaticamente um gráfico de dependências entre sistemas e instale-os nos lugares certos na ordem de execução.
No momento, estamos usando a terceira opção. Cada sistema indica quais componentes ele usa para leitura, quais para gravação e quais componentes ele cria. Em seguida, os sistemas são organizados automaticamente entre si na ordem necessária:
- O componente de leitura do sistema A vem depois que o sistema grava no componente A;
- O sistema que grava ou lê o componente B vem depois do sistema que cria o componente B;
- Se os dois sistemas gravarem no componente C, o pedido poderá ser qualquer (mas pode ser especificado manualmente, se necessário).
Em teoria, essa solução minimiza o controle sobre a ordem de execução; tudo o que é necessário é definir máscaras de componentes para o sistema. Na prática, com o crescimento do projeto, isso leva a mais e mais ciclos entre os sistemas. Se o sistema 1 grava no componente A e lê o componente B, e o sistema 2 lê o componente A e grava no componente B, esse é um ciclo e deve ser resolvido manualmente. Muitas vezes, existem mais de dois sistemas em um ciclo. Sua resolução requer tempo e indicações explícitas da relação entre eles.
Portanto, o Blitz Engine possui "grupos" de sistemas. Dentro dos grupos, os sistemas são alinhados automaticamente na ordem desejada (e os ciclos ainda são resolvidos manualmente) e a ordem dos grupos é definida explicitamente. Essa decisão é um cruzamento entre uma ordem totalmente manual e uma totalmente automatizada, e o tamanho dos grupos afeta seriamente sua eficácia. Assim que o grupo fica grande demais, os programadores novamente se deparam com os problemas de loops dentro deles.
Atualmente existem 10 grupos no Battle Prime. Isso ainda não é suficiente, e planejamos aumentar seu número criando uma sequência lógica estrita entre eles e usando a construção automática de um gráfico dentro de cada um deles.
A indicação de quais componentes são usados pelos sistemas para escrita ou leitura também permitirá no futuro agrupar automaticamente os sistemas em "blocos" que serão executados em paralelo entre si.
Abaixo está um utilitário auxiliar que exibe uma lista de sistemas e as dependências entre eles dentro de cada um dos grupos (gráficos completos dentro dos grupos parecem intimidadores). A cor laranja mostra dependências definidas explicitamente entre os sistemas:
Comunicação entre sistemas e sua configuração
As tarefas que os sistemas executam dentro de si podem, em um grau ou outro, depender dos resultados de outros sistemas. Por exemplo, um sistema que processa colisões de dois objetos depende de uma simulação da física que registra essas colisões. E o sistema de danos depende dos resultados do sistema balístico, responsável pelo movimento das conchas.
A maneira mais simples e óbvia de se comunicar entre sistemas é usar componentes. Um sistema adiciona os resultados de seu trabalho a um componente, e o segundo sistema lê esses resultados do componente e resolve seu problema com base.
Uma abordagem baseada em componentes pode ser inconveniente em alguns casos:
- E se o resultado do sistema não estiver diretamente vinculado a algum objeto? Por exemplo, um sistema que coleta estatísticas de batalha (o número de tiros, acertos, mortes etc.) - coleta globalmente, com base em toda a batalha;
- E se o sistema precisar ser configurado de alguma forma? Por exemplo, um sistema de simulação física precisa saber quais tipos de objetos devem registrar colisões entre si e quais não são.
Para resolver esses problemas, usamos a abordagem emprestada da equipe de desenvolvimento de Overwatch - Single Components.
Componente único é um componente que existe no mundo em uma única cópia e é obtido diretamente do mundo. Os sistemas podem usá-lo para adicionar os resultados de seu trabalho, que são usados por outros sistemas ou para configurar seu trabalho.
No momento, o projeto (módulos de mecanismo + jogo) possui cerca de 120 componentes únicos que são usados para diferentes fins - desde o armazenamento de dados globais do mundo até a configuração de sistemas individuais.
Abordagem "limpa"
Na sua forma mais pura, essa abordagem de sistemas e componentes requer a disponibilidade de dados somente dentro dos componentes e a presença de lógica somente dentro dos sistemas. Na minha opinião, na prática, essa restrição raramente faz sentido observar rigorosamente (embora os debates sobre esse assunto ainda sejam periodicamente levantados).
Os seguintes argumentos a favor de uma abordagem menos "rigorosa" podem ser destacados:
- Parte do código deve ser compartilhada - e executada de forma síncrona a partir de sistemas diferentes ou ao definir algumas propriedades dos componentes. Lógica semelhante é descrita separadamente. Como parte do mecanismo, usamos o termo Utils. Por exemplo, dentro do jogo `DamageUtils` contém a lógica associada à aplicação do dano - que pode ser aplicada a partir de diferentes sistemas;
- Não faz sentido manter os dados privados do sistema em algum lugar que não seja o próprio sistema - ninguém precisará deles, exceto os mesmos, e movê-los para outro local não é particularmente útil. Há uma exceção a essa regra, que está associada à funcionalidade das previsões do cliente - ela será descrita na seção abaixo;
- É útil que os componentes tenham uma pequena quantidade de lógica - na maioria dos casos, são getters e setters inteligentes que simplificam o trabalho com o componente.
Netcode
O Battle Prime usa uma arquitetura com um servidor autoritário e previsões de clientes. Isso permite que o jogador receba feedback instantâneo de suas ações, mesmo com altos pings e perdas de pacotes e o projeto como um todo - para minimizar a trapaça dos jogadores, porque o servidor determina todos os resultados da simulação dentro da batalha.
Todo o código dentro do projeto do jogo é dividido em três partes:
- Cliente - sistemas e componentes que funcionam apenas no cliente. Isso inclui coisas como interface do usuário, captura automática e interpolação;
- Servidor - sistemas e componentes que funcionam apenas no servidor. Por exemplo, tudo relacionado a personagens de dano e desova;
- Geral - é tudo o que funciona no servidor e no cliente. Em particular, todos os sistemas que calculam o movimento do personagem, o estado da arma (o número de rodadas, recargas) e tudo o mais que precisa ser previsto no cliente. A maioria dos sistemas responsáveis pelos efeitos visuais também é comum - o servidor pode ser iniciado opcionalmente no modo GUI (na maioria das vezes apenas para depuração).
Entrada do usuário (entrada)
Antes de passar para os detalhes de replicação e previsões no cliente, você deve continuar trabalhando com entrada dentro do mecanismo - os detalhes serão importantes nas seções abaixo.
Toda a entrada do player é dividida em dois tipos: baixo e alto nível:
- Entrada de baixo nível - são eventos de dispositivos de entrada, como pressionamentos de teclas, toque na tela e assim por diante. Essa entrada raramente é processada pelos sistemas de jogo;
- Entrada de alto nível - são as ações do usuário cometidas por ele no contexto do jogo: tiro, mudança de arma, movimento do personagem e assim por diante. Para ações de alto nível, usamos o termo "Ação". Além disso, dados adicionais podem ser associados à ação - como a direção do movimento ou o índice da arma selecionada. A grande maioria dos sistemas trabalha com ações.
Uma entrada de alto nível é gerada com base em ligantes a partir de uma entrada de baixo nível ou programaticamente. Por exemplo, uma ação de disparo pode ser ligada a um clique do mouse ou pode ser gerada pelo sistema responsável pelo disparo automático - assim que o jogador apontar para o inimigo, esse sistema gera um tiro de ação se o usuário tiver a configuração correspondente ativada. As ações também podem ser enviadas pelo sistema da interface do usuário: por exemplo, pressionando o botão correspondente ou ao mover o joystick na tela. Um sistema acionado não importa como essa ação foi criada.
Ações logicamente relacionadas são agrupadas (objetos do tipo `ActionSet`). Os grupos podem ser desconectados se não forem necessários no contexto atual - por exemplo, no Battle Prime, existem vários grupos, entre os quais:
- Ações para controlar o movimento do personagem,
- Ações para disparar armas automáticas,
- Ações para disparar armas semi-automáticas.
Dos dois últimos grupos, apenas um está ativo por vez, dependendo do tipo de arma selecionada - eles diferem na maneira como a ação FIRE é gerada: enquanto o botão é pressionado (para armas automáticas) ou apenas uma vez quando o botão é pressionado (para armas semiautomáticas )
Da mesma forma, grupos de ações são criados e configurados dentro do jogo em um dos sistemas:
static const Map<FastName, ActionSet> action_sets = { {
Battle Prime descreve cerca de 40 ações. Alguns deles são usados apenas para depuração ou gravação de clipes.Replicação
Replicação é o processo de transferência de dados de um servidor para clientes. Todos os dados são transmitidos através de objetos no mundo:- Sua criação e exclusão,
- Criando e excluindo componentes em objetos,
- Alterar propriedades do componente.
A replicação é configurada usando o componente apropriado. Por exemplo, de maneira semelhante, o jogo configura a replicação das armas do jogador: auto* replication_component = weapon_entity.add<ReplicationComponent>(); replication_component->enable_replication<WeaponDescriptorComponent>(Privacy::PUBLIC); replication_component->enable_replication<WeaponBaseStatsComponent>(Privacy::PUBLIC); replication_component->enable_replication<WeaponComponent>(Privacy::PRIVATE); replication_component->enable_replication<BallisticsStatsComponent>(Privacy::PRIVATE);
Para cada componente, a privacidade usada durante a replicação é indicada. Os componentes privados serão enviados do servidor apenas para o jogador que possui esta arma. Os componentes públicos serão enviados a todos. Neste exemplo, o `WeaponDescriptorComponent` e o` WeaponBaseStatsComponent` são públicos - eles contêm os dados necessários para a exibição correta de outros jogadores. Por exemplo, o índice do slot no qual a arma está e seu tipo são necessários para as animações. Os componentes restantes são enviados em particular ao jogador que possui esta arma - os parâmetros da balística das cartuchas, informações sobre o número total de rodadas, os modos de tiro disponíveis e assim por diante. Existem modos de privacidade mais especializados: por exemplo, você pode enviar um componente apenas para aliados ou apenas para inimigos.Cada componente em sua descrição deve indicar quais campos devem ser replicados dentro desse componente. Por exemplo, todos os campos dentro do `WeaponComponent` são marcados como` Replicable`: BZ_VIRTUAL_REFLECTION_IMPL(WeaponComponent) { ReflectionRegistrar::begin_class<WeaponComponent>() .ctor_by_pointer() .copy_ctor_by_pointer() .field("owner", &WeaponComponent::owner)[M<Replicable>()] .field("fire_mode", &WeaponComponent::fire_mode)[M<Replicable>()] .field("loaded_ammo", &WeaponComponent::loaded_ammo)[M<Replicable>()] .field("ammo", &WeaponComponent::ammo)[M<Replicable>()] .field("shooting_cooldown_end_ms", &WeaponComponent::shooting_cooldown_end_ms)[M<Replicable>()]; }
Este mecanismo é muito conveniente de usar. Por exemplo, dentro do sistema do servidor, responsável por “ejetar” os tokens dos oponentes mortos (em um modo de jogo especial), basta adicionar e configurar o 'ReplicationComponent` nesse token. É assim: for (const Component* component : added_dead_avatars->components) { Entity kill_token_entity = world->create_entity();
Neste exemplo, a simulação física do token durante a perda ocorrerá no servidor e a transformação final do token será enviada e aplicada no cliente. Um sistema de interpolação também funcionará no cliente, o que facilitará o movimento desse token, levando em consideração a frequência das atualizações, a qualidade da conexão com o servidor e assim por diante. Outros sistemas associados a este modo de jogo adicionam uma parte visual aos objetos com `KillTokenComponent` e monitoram sua seleção.O único inconveniente da abordagem atual que você deseja prestar atenção e da qual deseja se livrar no futuro é a incapacidade de definir privacidade para cada campo de componente. Isso não é muito crítico, pois um problema semelhante pode ser facilmente resolvido dividindo-se o componente em vários: por exemplo, o jogo contém `ShooterPublicComponent` e` ShooterPrivateComponent` com a privacidade correspondente. Apesar de vinculados a um mecânico (tiro), é necessário ter dois componentes para economizar tráfego - alguns dos campos simplesmente não são necessários para clientes que não possuem esses componentes. No entanto, isso adiciona trabalho ao programador.Em geral, os objetos replicados para um cliente podem ter estados para diferentes quadros. Portanto, a capacidade de agrupar objetos formando grupos de replicação foi adicionada. Todos os componentes em objetos dentro do mesmo grupo sempre têm um estado para o mesmo quadro no cliente - isso é necessário para que as previsões funcionem corretamente (mais sobre eles abaixo). Por exemplo, uma arma e um personagem que a possui estão no mesmo grupo. Se os objetos estiverem em grupos diferentes, seu estado no mundo poderá ser para quadros diferentes.O sistema de replicação tenta minimizar a quantidade de tráfego, em particular compactando os dados transmitidos (cada campo dentro do componente pode ser opcionalmente marcado de acordo para a compactação) e transmitindo apenas a diferença de valores entre os dois quadros.Previsões de clientes
As previsões do cliente (o termo previsão do lado do cliente é usado em inglês) permitem que o jogador receba feedback instantâneo sobre a maioria de suas ações no jogo. Ao mesmo tempo, como a última palavra está sempre atrás do servidor, no caso de um erro na simulação (o termo erro de previsão é usado em inglês, no futuro os chamarei simplesmente de "previsões errôneas"), o cliente deve corrigi-lo. Mais detalhes sobre erros de previsão e como eles são corrigidos serão descritos abaixo.As previsões do cliente funcionam de acordo com as seguintes regras:- O cliente simula-se adiante por N frames;
- Toda entrada gerada pelo cliente é enviada ao servidor (na forma de ações executadas pelo jogador);
- N depende da qualidade da conexão com o servidor. Quanto menor esse valor, mais "atualizada" a imagem do mundo é para o cliente (ou seja, o intervalo de tempo entre o jogador local e outros jogadores é menor).
Como resultado, o servidor e o cliente executam a simulação com base na entrada do cliente. O servidor envia os resultados dessa simulação para o cliente. Se o cliente determinar que seus resultados não coincidem com os do servidor, ele tenta corrigir o erro - retorna ao último estado conhecido do servidor e simula novamente N quadros à frente. Então, tudo continua de acordo com um esquema semelhante - o cliente continua a simular-se no futuro em relação ao servidor, e o servidor envia os resultados de sua simulação. Daqui resulta que todo o código que afeta as previsões do cliente deve ser compartilhado entre o cliente e o servidor.Além disso, para economizar tráfego, toda a entrada é compactada com base em um esquema predefinido. Em seguida, ele é enviado ao servidor e imediatamente descompactado de volta ao cliente. O empacotamento e a descompactação subsequente no cliente são necessários para eliminar a diferença nos valores associados à entrada entre o cliente e o servidor. Ao criar um esquema, o intervalo de valores para esta ação é indicado e o número de bits nos quais ela deve ser compactada. Da mesma forma, o anúncio do esquema de empacotamento no Battle Prime parece dentro de um sistema comum entre o cliente e o servidor: auto* input_packing_sc = world->get_for_write<InputPackingSingleComponent>(); input_packing_sc->packing_schema = { { ActionNames::MOVE, AnalogStatePrecision{ 8, { -1.f, 1.f }, false } }, { ActionNames::LOOK, AnalogStatePrecision{ 16, { -PI, PI }, false } }, { ActionNames::JUMP, nullopt },
Uma condição crítica para que o desempenho das previsões do cliente funcione é a necessidade de que a entrada tenha tempo para chegar ao servidor no momento em que a simulação de quadro à qual essa entrada está relacionada. Se a entrada não conseguiu chegar ao servidor no quadro desejado (isso pode acontecer, por exemplo, com um salto acentuado de ping), o servidor tentará usar a entrada desse cliente do quadro anterior. Este é um mecanismo de backup que pode ajudar a se livrar das previsões errôneas no cliente em algumas situações. Por exemplo, se um cliente simplesmente executar em uma direção e sua entrada não for alterada por um tempo relativamente longo, o uso de entrada para o último quadro será bem-sucedido - o servidor irá “adivinhar” e não haverá discrepância entre o cliente e o servidor. Um esquema semelhante é usado em Overwatch (foi mencionado em uma palestra no GDC:www.youtube.com/watch?v=W3aieHjyNvw ).Atualmente, o cliente Battle Prime prevê o status dos seguintes objetos:- Avatar do jogador (posição no mundo e tudo o que pode afetá-lo, estado das habilidades, etc.);
- Todas as armas do jogador (número de rodadas na loja, recargas entre tiros, etc.).
O uso de previsões do cliente se resume a adicionar e configurar o `PredictionComponent` no cliente aos objetos desejados. Por exemplo, a previsão do avatar de um jogador em um dos sistemas é ativada de maneira semelhante:
Esse código significa que os campos dentro dos componentes acima serão comparados constantemente com os mesmos campos dos componentes do servidor - se for observada uma discrepância nos valores em um único quadro, será feito um ajuste no cliente.O critério de discrepância depende do tipo de dados. Na maioria dos casos, isso é apenas uma chamada para `operator ==`, a exceção são os dados baseados em float - para eles, o erro máximo permitido atualmente é corrigido e é igual a 0,005. No futuro, há um desejo de adicionar a capacidade de definir a precisão de cada campo de componente separadamente.O fluxo de trabalho de replicação e previsão do cliente é baseado no fato de que todos os dados necessários para a simulação estão contidos nos componentes. Acima, na seção ECS, escrevi que os sistemas podem armazenar parte dos dados - isso pode ser conveniente em alguns casos. Isso não se aplica a nenhum dado que afeta a simulação - ele deve sempre estar dentro dos componentes, pois os sistemas de captura instantânea do cliente e do servidor funcionam apenas com os componentes.Além de prever valores de campo dentro de componentes, é possível prever a criação e remoção de componentes. Por exemplo, se, como resultado do uso da habilidade, um `SpeedModifierComponent` for sobreposto ao personagem (que modifica a velocidade do movimento, por exemplo, acelera o jogador), ele deve ser adicionado ao personagem no servidor e no cliente no mesmo quadro; caso contrário, ele levará a uma previsão incorreta da posição do personagem no cliente.A previsão de criação e exclusão de objetos não é suportada no momento. Isso pode ser conveniente em algumas situações, mas também complicará os módulos de rede. Talvez voltemos a isso no futuro.Abaixo está um gif no qual o controle de caracteres ocorre com a RTT por cerca de 1,5 segundos. Como você pode ver, o personagem é controlado instantaneamente, apesar do alto atraso: movimento, tiro, recarga, lançamento de granada - tudo acontece sem esperar pelas informações do servidor. Você também pode notar que a captura de um ponto (uma zona limitada por triângulos) começa com um atraso - essa mecânica funciona apenas no servidor e não é prevista pelo cliente.Previsões errôneas e simulações
Erro de impressão - discrepância entre os resultados das simulações do servidor e do cliente. Ressimulação é o processo de correção dessa discrepância pelo cliente.A primeira razão para a aparência de erros de previsão são os saltos acentuados de ping, pelos quais o cliente não teve tempo de se ajustar. Em tal situação, a entrada do player pode não ter tempo para chegar ao servidor, e o servidor usará o mecanismo de backup descrito acima com duplicação da última entrada por algum tempo e, após algum tempo, parará de usá-lo.O segundo motivo é a interação do personagem com objetos que são totalmente controlados pelo servidor e não são previstos localmente pelo cliente. Por exemplo, uma colisão com outro jogador causará uma imprevisibilidade - uma vez que eles, de fato, vivem em dois períodos de tempo diferentes (um personagem local é no futuro relativo a outro jogador - cuja posição vem do servidor e é interpolada).O terceiro e mais desagradável motivo são os erros no código. Por exemplo, um sistema pode usar dados não replicados por engano para controlar a simulação, ou os sistemas funcionam na ordem errada ou mesmo em ordens diferentes no servidor e no cliente.Encontrar esses bugs às vezes leva uma quantidade decente de tempo. Para simplificar sua pesquisa, criamos várias ferramentas auxiliares - enquanto o aplicativo está em execução, você pode ver:- Componentes replicados
- O número de previsões errôneas
- Em quais quadros eles aconteceram,
- Quais dados estavam no servidor e no cliente nos componentes divergentes,
- Que entrada foi aplicada no servidor e no cliente para esse quadro.
Infelizmente, mesmo com eles, a busca pelas causas dos reavivamentos ainda leva um tempo decente. Sem dúvida, ferramentas e validações precisam ser desenvolvidas para reduzir a probabilidade de erros e simplificar sua pesquisa.Para apoiar a operação de re-simulações, o sistema deve herdar de uma classe específica `ResimulatableSystem`. Em uma situação em que uma imprevisibilidade ocorre, o mundo “reverte” todos os objetos para o último estado conhecido do servidor e, em seguida, faz o número necessário de simulações para corrigir esse erro - somente sistemas ressimuláveis participarão disso.Em geral, as simulações de clientes não devem ser percebidas pelos jogadores. Quando eles ocorrem, todos os campos de componentes são interpolados suavemente em novos valores para suavizar visualmente possíveis "contrações". No entanto, é essencial manter o número o mais baixo possível.Tiro
Os danos aos jogadores são completamente ditados pelo servidor - os clientes não podem confiar em uma mecânica tão importante para reduzir a chance de trapaça. Mas, como o movimento, atirar no cliente deve ser o mais responsivo possível e sem atrasos - o jogador precisa receber feedback instantâneo na forma de efeitos e sons - focinho, traço do voo do projétil, bem como os efeitos do projétil atingindo o entorno e outros jogadores.Portanto, todo o estado do personagem associado ao disparo é previsto pelo cliente - quantas rodadas há na loja, a dispersão durante o disparo, o atraso entre os disparos, a hora do último disparo e assim por diante. Também no cliente existem os mesmos sistemas responsáveis pela movimentação de shells que no servidor - isso permite simular disparos no cliente sem aguardar os resultados de sua simulação no servidor.As balísticas das próprias conchas não são previstas - uma vez que voam a uma velocidade muito alta e, por via de regra, terminam seu movimento em alguns quadros, o shell já terá tempo para chegar a algum ponto do mundo e perder o efeito antes de obtermos os resultados da simulação este é um projétil do servidor (ou a falta de resultados se, devido a uma imprevisibilidade, o cliente disparou o projétil por engano).O esquema de trabalho de projéteis voando lentamente é um pouco diferente. Se um jogador lança uma granada, mas como resultado da imprevisibilidade, a granada não foi lançada, ela será destruída no cliente. Da mesma forma, se um cliente previu incorretamente a destruição de uma granada (ela já explodiu no servidor, mas ainda não no cliente), a granada do cliente também será destruída. Todas as informações sobre as explosões exibidas no cliente vêm do servidor para evitar situações em que, como resultado de um erro do cliente, a explosão do servidor ocorreu em um local e no cliente em outro.Idealmente, eu gostaria de prever completamente conchas que voam lentamente no futuro - não apenas o tempo da vida, mas também a posição delas.Compensação de atraso
A compensação de retardo é uma técnica que permite nivelar o efeito do atraso entre o servidor e o cliente na precisão do disparo. Nesta seção, assumirei que o tiro sempre vem de armas "hitscan" - ou seja, um projétil disparado por uma arma viaja em velocidade infinita. Mas tudo o que é descrito aqui também importa com outros tipos de armas.Os seguintes pontos tornam necessário compensar o atraso ao fotografar:- O personagem sob o controle do jogador é, no futuro, relativo ao servidor (prevendo seu estado para um certo número de quadros à frente);
- Consequentemente, o restante dos jogadores está relativamente no passado;
- Quando acionada, a ação correspondente é enviada pelo cliente ao servidor e aplicada no mesmo quadro em que foi aplicada no cliente (se possível).
Se assumirmos que o jogador está mirando em um inimigo correndo em direção à cabeça e pressionar o botão de tiro, a seguinte imagem é obtida:- No cliente: o atirador no quadro N1 dispara um tiro na cabeça de um inimigo localizado no quadro N0 (N0 <N1);
- No servidor: o atirador no quadro N1 dispara um tiro na cabeça do inimigo, também localizado no quadro N1 (no servidor, todos são ao mesmo tempo).
O resultado disso, com uma alta probabilidade, é um erro durante um tiro. Como o cliente mira com base em sua imagem do mundo, que não coincide com a imagem do mundo do servidor, para entrar no inimigo, ele precisa apontar para ele mesmo ao usar armas hitscan, e a distância na qual ele deve disparar depende da qualidade da conexão com o servidor. Isto, para dizer o mínimo, não é uma boa experiência para um atirador.Para se livrar desse problema, a compensação de atraso é usada. O esquema de seu trabalho é o seguinte:- O servidor possui um histórico de tamanho limitado de instantâneos do mundo;
- Quando disparados, os inimigos (ou parte dos inimigos) "retrocedem" de forma que o mundo no servidor corresponda ao mundo que o cliente viu em si - o cliente está no "presente" (o momento do disparo) e os inimigos estão no passado;
- Mecânica de detecção de acerto funciona, acertos são registrados;
- O mundo está retornando ao seu estado original.
Como a imagem do mundo no cliente também depende da operação do sistema de interpolação, para "reverter" o mundo para o estado mais preciso do cliente no servidor, o cliente fornece dados adicionais - a diferença entre o quadro atual do cliente e o quadro para o qual ele vê todos os outros jogadores (no momento, são dois bytes por quadro), bem como o tempo de geração da entrada da tomada em relação ao início do quadro.A compensação de atraso existe no nível de um módulo separado dentro do mecanismo e não está vinculada a um projeto específico. Do ponto de vista do desenvolvedor da mecânica de jogo, seu uso é o seguinte:- LagCompensationComponent é adicionado ao player e a lista de hitboxes a serem armazenadas no histórico é preenchida;
- Ao fotografar (ou outras mecânicas que exigem compensação - por exemplo, em ataques corpo a corpo), `LagCompensation :: invoke` é chamado, onde o functor é passado, que será executado no" compensado ", do ponto de vista de um jogador em particular, mundo. Ele deve ter toda a detecção de ocorrências necessária.
Codifique com um exemplo do uso de compensação de atraso do Batle Prime ao mover projéteis balísticos:
Também gostaria de observar que a compensação de atraso é um esquema que coloca a experiência do atirador acima da experiência do alvo em que ele está atirando. Do ponto de vista do alvo, o inimigo pode entrar nele quando já está atrás de um obstáculo (uma reclamação frequente nos fóruns de jogos). Para fazer isso, a compensação de atraso possui um número limitado de quadros para os quais os objetivos podem ser "bombeados". No momento, no Battle Prime, um atirador com um RTT de cerca de 400 milissegundos pode acertar confortavelmente inimigos. Se o RTT for maior, você terá que ir adiante.Um exemplo de tiro sem compensação - você precisa disparar à frente para atingir constantemente o inimigo:E com compensação - você pode apontar confortavelmente diretamente para o inimigo:Nossos agentes de construção também executam periodicamente testes automáticos que verificam o trabalho de diferentes mecânicos. Entre eles, há também um autoteste para precisão de disparo com a compensação de atraso ativada. No gif abaixo, este teste é mostrado - o personagem simplesmente atira na cabeça de um inimigo que passa correndo e conta o número de acertos nele. Para depuração, as caixas de acerto do inimigo que estavam no servidor no momento do disparo (em branco) e as caixas de acerto usadas para a detecção de acertos dentro do mundo compensado (em azul) também são exibidas:
Um fator adicional que afeta a precisão do disparo é a posição das caixas de acerto no personagem. As caixas de ocorrências dependem de animações esqueléticas e, atualmente, suas fases não são sincronizadas de forma alguma, portanto é possível que as caixas de ocorrências sejam diferentes entre o cliente e o servidor. As conseqüências disso dependem das próprias animações - quanto maior a amplitude de movimento dentro da animação, maior a diferença potencial na posição das caixas de acerto entre o servidor e o cliente. Na prática, essa diferença não é perceptível para o jogador e afeta mais a parte inferior do corpo, o que é menos crítico em comparação com a parte superior (cabeça, tronco, braços). No entanto, no futuro, gostaria de abordar com mais detalhes a questão da sincronização de animações entre o servidor e o cliente.Conclusão
Neste artigo, tentei descrever a base sobre a qual o Battle Prime se baseia - esta é a implementação do padrão ECS dentro do Blitz Engine, bem como o módulo de rede responsável pela replicação, pelas previsões dos clientes e pela mecânica relacionada. Apesar de algumas falhas (que continuamos trabalhando na correção), o uso dessa funcionalidade agora é simples e conveniente.Para mostrar a imagem geral do Battle Prime, tive que abordar um grande número de tópicos. Muitos deles podem ser dedicados a separar artigos no futuro, nos quais serão descritos com mais detalhes!O jogo já está sendo testado na Turquia e nas Filipinas.Nossos artigos anteriores podem ser encontrados nos seguintes links:- habr.com/en/post/461623
- habr.com/en/post/465343