Servidor de jogos em 150 linhas de ferrugem

Jay Butera Blocked Unblock Seguir Seguindo 10 de janeiro Este é o Black Desert Online. Nós não vamos fazer isso.

Ok, o título faz parecer que você estará escrevendo servidores de jogo em 5 minutos. E você pode pular direto para o código-fonte, se é isso que você quer. No entanto, o objetivo deste artigo é fornecer informações sobre as decisões de design no código e, possivelmente, para ajudá-lo a conceituar como um servidor de jogo funciona no caso geral. O artigo também deve ser útil se você estiver apenas aprendendo a linguagem de programação Rust ou suas bibliotecas assíncronas. Em 150 linhas, não será um ótimo jogo. Na verdade, dificilmente pode ser chamado de jogo. Mas o foco está em uma infraestrutura de servidor escalável que pode ser expandida para suportar um jogo. No código eu uso os futuros assíncronos de Rust com o tempo de execução do tokio . Se você não tiver olhado para eles, mas entender a programação assíncrona, talvez do NodeJS, você deve estar bem. É claro que é muito possível criar um servidor sem esses recursos sofisticados, mas acredito que torne o código mais legível e mais fácil de raciocinar. Como esses recursos ainda são novos, tentarei explicar as suposições básicas sobre os futuros e o tempo de execução.

O termo “servidor de jogos” é um pouco ambíguo. O que quero dizer é um servidor em tempo real no qual um cliente-jogador pode estabelecer uma conexão para assinar atualizações de estado mundiais consistentes com alguma frequência, e pode enviar comandos ao servidor para atualizar sua própria entidade como parte do estado mundial. Para esclarecer um pouco mais, sim World of Warcraft, sim League of Legends, não Hearthstone.

Ao contrário de Hearthstone, o mundo do jogo continuará a agitar se qualquer jogador fizer ou não um movimento. Isso distingue o programa de um simples serviço reativo. O servidor precisa de um loop principal para processar e transmitir o estado do jogo em intervalos de tempo. Quando o servidor manipula novas conexões de jogadores ou comandos de jogadores? Em um único programa encadeado, ele pode fazer parte do loop.

 fn main () { 
// Estado do jogo de processo
para e em & entities {
e.update ();
}
 // Lidar com novas conexões 
...
// Processar novos comandos de jogador
...
 // Atrasar 1/60 de segundo 
...
}

Talvez tenhamos um buffer grande que acumule novos comandos do player para que o programa faça uma iteração em cada ciclo. Mas a frequência do loop do jogo é agora acoplada ao processamento de pedidos do cliente. Um servidor de jogo geralmente é executado a uma taxa fixa de algo em torno de 60 vezes por segundo. Isso é muito lento para processar um número potencialmente grande de conexões de clientes.

Vou ressaltar que há um benefício dessa abordagem, mas não quero prolongá-la porque está além do escopo da minha intenção neste artigo. Isso é que é fácil garantir que as atualizações do cliente sejam processadas na ordem em que são recebidas. Isso é importante em um mecanismo de jogo, mas há menos preocupação em um servidor porque os pacotes enviados pela Internet já podem chegar em diferentes pedidos e velocidades. Mas chega disso.

Para dimensionar esse servidor, vamos torná-lo multi-threaded. Uma intuição simples pode ser dividir o tratamento de mensagens do cliente e o loop do jogo em dois threads simultâneos. E isso é uma boa ideia. Mas podemos ir mais longe, e vamos porque o tokio torna isso mais fácil. Com a abordagem de encadeamentos designados, o encadeamento de tratamento do cliente ainda pode manipular somente tantas mensagens quanto um encadeamento possa processar. Em vez disso, usaremos o executor thread_pool do tokio para carregar as tarefas em um conjunto de threads. Aqui está a configuração.

 deixe runtime = tempo de execução :: Builder :: new (). build (). unwrap (); 
deixe executor = runtime.executor ();
deixe server = Server :: bind ("127.0.0.1:8080", & runtime.reactor ()). unwrap ();
 // Hashmap para armazenar um valor de coletor com uma chave id 
Permitir conexões = Arc :: new (RwLock :: new (HashMap :: new ()));
 // Hashmap de id: entity pairs. Este é basicamente o estado do jogo 
deixe entidades = Arc :: new (RwLock :: new (HashMap :: novo ()));
 // Usado para atribuir um id exclusivo a cada novo jogador 
deixe o contador = Arc :: new (RwLock :: new (0));

Se você não estiver familiarizado com os tipos de dados Arc e RwLock / Mutex, recomendo que você leia o capítulo Estado Compartilhado do livro Rust. Além disso, meu artigo anterior é uma boa introdução para lidar com o estado compartilhado em encerramentos.

Ora aqui está a parte muito legal. Ao aceitar uma conexão com Websockets, o futuro retorna um objeto de conexão que pode ser dividido em dois objetos, comumente chamados de coletor e fluxo. Esse fluxo (confusamente também é um fluxo de futuros, pois é um iterador que retorna futuros) é invocado em novas mensagens da conexão. Em oposição, o coletor é usado para enviar mensagens para baixo a conexão para o cliente. Ao dividir as duas funcionalidades – envio e recebimento – podemos dividir o programa em duas seções lógicas, receber manuseio e enviar manuseio.

 deixe connection_handler = server.incoming () 
.map_err (| InvalidConnection {error, ..} | erro)
.for_each (move | (upgrade, addr) | {
deixe executor_inner = executor.clone ();
 // "accept ()" conclui a conexão 
vamos aceitar = upgrade.accept (). and_then (move | (framed, _) | {
let (afundar, fluxo) = framed.split ();
 // Incrementa o contador bloqueando primeiro o RwLock 
{
vamos mut c = counter.write (). unwrap ();
* c + = 1;
}
let id = * counter_inner.read (). unwrap ();
 // Armazena o coletor com um ID exclusivo 
connections.write (). unwrap (). insert (id, sink);
 // Atribui uma nova entidade ao mesmo id 
entidades.write (). unwrap (). insert (
identidade,
tipos :: Entity {id, pos: 0}
);
vamos c = * counter.read (). unwrap ();
 // Gerar um fluxo para futuras mensagens deste cliente 
deixe f = stream.for_each (move | msg | {
process_message (c, & msg, entities.clone ());
Está bem(())
}). map_err (| _ | ());
 executor_inner.spawn (f); 
Está bem(())
}). map_err (| _ | ());
 executor.spawn (accept); 
Está bem(())
})
.map_err (| _ | ());

Connection_handler é um fluxo de futuros que será invocado cada vez que um novo cliente se conecta ao servidor. O fechamento em for_each (..) (linha 3) é onde a lógica vai para o que fazer em cada nova conexão.

Primeiro, o executor deve ser clonado (lembre-se que é um tipo de arco) a ser usado no fechamento interno “accept” (linha 7). Este é realmente um conceito muito importante, e se você olhar para o código-fonte real deste servidor, verá muito mais clonagem. Eles tendem a crescer em número com o tamanho do código, infelizmente. Você pode se perguntar por que o fechamento “accept” precisa capturar variáveis com o comando “move”. O fato é que, no momento, os futuros tomam fechamentos com vidas estáticas. Isso porque é difícil saber quando um futuro terminará a execução, e provavelmente ele viverá mais tempo do que o escopo definido. Os futuros ainda são um recurso inicial no Rust. Eu imagino no “futuro”… hah… A Rust aceitará limites de vida mais rigorosos em fechamentos futuros, mas por enquanto o fechamento deve apropriar-se de quaisquer variáveis que ele usa para ter certeza de que elas não estão fora do escopo. É por isso que você vê todos os encerramentos futuros usando "mover".

Observe além do clone na linha 4, toda a lógica do programa está no futuro interno "aceitar". Na execução, isso significa que, assim que uma nova conexão for estabelecida, o connection_handler gerará uma tarefa “accept” para processar a nova conexão, que pode ser executada em outro thread. Assim que a tarefa é gerada, o connection_handler pode retornar para escutar novas conexões. Isso é eficiente.

Dentro do futuro aceito, o contador de ID exclusivo é incrementado e uma nova entrada no hashmap de conexões é inicializada para armazenar o mapeamento id: sink. Assim como a entidade hashmap para armazenar o id: entity mapping. Você pode criar estruturas de dados mais eficientes para rastrear o estado do seu player, mas isso não é tão ruim para duas linhas de código. Finalmente, o fluxo “f” é gerado para processar cada nova mensagem do jogador. Mesmo que a tarefa “aceitar” termine, “f” continuará processando mensagens para sempre. Esta é a beleza da programação assíncrona para declarar exatamente como um programa deve responder em um dado estímulo, mas abstrair o “quando” a ser determinado pelo tempo de execução interno.

Eu decidi não mostrar a função "process_message" aqui. São algumas linhas simples e você pode ver isso no código-fonte. Essencialmente, se o jogador envia o comando "esquerda", a entidade é movida uma para a esquerda e vice-versa para a "direita". Observe que a função usa um clone de entidades para modificar o estado. Observe também que process_message não tem seu próprio futuro – os blocos de fluxo “f” até que ele seja concluído. Isso porque não queremos a possibilidade de um novo comando de execução do jogador antes que um antigo tenha a chance.

Agora, para a segunda metade do programa, o send_handler.

 vamos send_handler = future :: loop_fn ((), move | _ | { 
deixe connections_inner = connections.clone ();
deixe executor = executor.clone ();
let entities_inner = entities.clone ();
 tokio :: timer :: Delay :: new (Instant :: agora () + Duração :: from_millis (100)) 
.map_err (| _ | ())
.and_then (move | _ | {
vamos mut conn = connections_inner.write (). unwrap ();
deixar ids = conn.iter ()
.map (| (k, v) | {k.clone ()}). collect :: <Vec <_ >> ();
 para id em ids.iter () { 
vamos sink = conn.remove (id) .unwrap ();
 // Meticulosamente serializar vetor de entidade em json 
let serial_entities = ... // Omitido
 permite conexões = connections_inner.clone (); 
let id = id.clone ();
 deixe f = afundar 
.send (OwnedMessage :: Text (serial_entities))
.and_then (move | sink | {
// Entrada volta ao mapa
connections.write (). unwrap ()
.insert (id.clone (), sink);
Está bem(())
})
.map_err (| _ | ());
 executor.spawn (f); 
}
 // Estranho jeito de dizer loop para sempre 
match true {
true => Ok (Loop :: Continue (())),
false => Ok (Loop :: Quebrar (())),
}
})
});

loop_fn () é um futuro recursivo de cauda que facilita o loop de futuros indefinidamente. Bom para um loop de jogo. Cada loop, há um atraso de 100 ms antes de executar a lógica do núcleo. A lógica é um pouco estranha aqui, então vou primeiro explicar o que está efetivamente acontecendo, então explico como isso é feito em Rust com suas regras de propriedade. Funcionalmente, tudo isso faz é iterar através dos ids de cada jogador, serializar o estado do jogo (todas as entidades) em json, então enviar esse json para o cliente do id correspondente. Uma otimização óbvia seria apenas serializar uma vez, não em cada ciclo do loop. Mas vou deixar isso como um exercício.

O verificador de empréstimo Rust é um adversário notório para os recém-chegados da língua. O código acima é um exemplo de um cenário que pode realmente ser melhorado para ser mais amigável ao programador. Pelo menos sintaticamente. A questão aqui é que um hashmap só retornará uma referência a um de seus valores (o coletor nesse caso). No entanto, precisamos da propriedade total do coletor para enviar uma mensagem ao jogador. Para fazer isso, devemos remover a entrada do hashmap (primeira linha do loop de ids) e reinseri-la quando terminar de enviar (dentro do "f" futuro). Isso não é tão ruim assim que você vê como funciona.

A questão anterior leva à próxima esquisitice. Por que alocar um novo vetor de ids ao invés de apenas iterar através do hashmap de conexões? A razão é que um iterador toma emprestado o eu imutavelmente, então, dentro do loop, o “conn” é mutuamente emprestado ao remover uma entrada do hashmap. A maneira mais fácil de contornar isso é apenas clonar os ids porque eles são baratos u32.

 para id em conn.iter () {// Empréstimo imutável 
conn.remove (id) .unwrap (); // Empréstimo mutável não permitido

Finalmente, você notará que serial_entities não está definido. É apenas uma conversão para o json e é um pouco feio por causa de um caso extremo que eu tive que lidar. A implementação pode ser vista no código-fonte, mas achei que apenas subtraia os bits importantes aqui.

Para amarrar o programa, usamos um combinador de futuros; selecione (..). Select combina os fluxos de futuros connection_handler e send_handler em um futuro que é concluído quando um dos dois é concluído. Lembre-se, o único caso em que um desses futuros terminaria seria um erro fatal.

 tempo de execução 
.block_on_all (connection_handler.select (send_handler))
.map_err (| _ | println! ("Erro ao executar o loop principal"))
.desembrulhar();

Isso é tudo. Você deve ter notado que eu não defini a estrutura da entidade em nenhum lugar. É uma definição simples, então eu a coloco no arquivo types.rs, que pode ser encontrado no código-fonte. Também no repositório é um cliente de canvas html5 para fazer interface com este servidor. Experimente, pontos de bônus se você jogar multiplayer com um amigo.

Se você está se perguntando como consertar a gagueira no movimento, a solução não é aumentar o tempo de resposta do servidor. Em vez disso, use um LERP do lado do cliente para suavizar a diferença.