
1. Introdução
A história começou com um hackathon baseado em blockchain. No início do evento, conheci um homem que cria jogos de tabuleiro como hobby (eu estava no teste de um desses jogos), nos unimos e encontramos uma equipe com a qual eles “cegaram” um jogo estratégico simples no fim de semana. O hackathon passou, mas o entusiasmo permaneceu. E surgiu a idéia de um jogo de cartas multiplayer sobre felicidade, comunidade mundial e eleições.
Na série de artigos, refletiremos nosso caminho para a criação de um jogo, com uma descrição do rake em que já pisamos e seguiremos em frente.
Sob o corte será:
- Resumo do Jogo
- Como a decisão foi tomada sobre o que fazer back-end. Onde ele “viverá” para não pagar por isso no estágio de desenvolvimento
- Primeiros passos no desenvolvimento - autenticação de jogadores e organização de matchmaking
- Planos adicionais
Sobre o que é o jogo
A humanidade está cansada de guerras mundiais, esgotamento de recursos e concorrência constante. As principais facções concordaram em usar a tecnologia moderna para selecionar uma única liderança. Na hora marcada, o eleitorado mundial deve decidir sobre a escolha de uma fração que governará o planeta pelo próximo milênio. As facções principais se envolvem em uma luta pelo poder "honesta". Em uma sessão de jogo, cada jogador representa uma fração.
Este jogo de cartas é sobre eleições. Cada facção tem um orçamento para conduzir a corrida eleitoral, fontes de renda que aumentam o orçamento e votos iniciais. No início do jogo, o baralho com cartas de ação é misto e 4 cartas são emitidas para cada participante. A cada turno, os jogadores podem realizar até duas ações do jogo. Para usar o cartão, o jogador o coloca na mesa e, se necessário, designa a meta e deduz do orçamento o custo de uso do cartão. Após o final da rodada, o jogador pode manter apenas uma das cartas não utilizadas. No início de cada rodada, os jogadores recebem cartas do baralho, de modo que, no início de cada rodada, cada jogador tenha 4 cartas de ação na mão.
No final das rodadas 3, 6 e 9, o jogador com o menor número de votos é removido do jogo. Se vários jogadores tiverem o mesmo número mínimo de votos, todos os jogadores com esse resultado serão eliminados do jogo. As vozes desses jogadores vão para a piscina geral do eleitorado.
No final da rodada 12, o vencedor é o com mais votos.
Escolhendo uma ferramenta para o back-end
A partir da descrição do jogo, segue:
- Este é multiplayer
- É necessário, de alguma forma, identificar jogadores e gerenciar contas
- A presença de um componente social beneficiaria o jogo - amigos, comunidades (clãs), chats, conquistas (conquistas)
- Serão necessárias tabelas de classificação e funcionalidade de correspondência.
- A funcionalidade de gerenciamento de torneios será útil no futuro
- Dado que o jogo é um jogo de cartas, você precisa gerenciar o catálogo de cartas, pode ser necessário armazenar as cartas disponíveis para o jogador e os baralhos compilados
- No futuro, poderá ser necessária uma economia no jogo, incluindo moeda no jogo, troca de bens virtuais (cartões)
Olhando para a lista de necessidades, cheguei imediatamente à conclusão de que criar meu próprio back-end no estágio inicial não faz sentido e fui ao Google quais são as outras opções. Então, descobri que existem backends especializados em jogos em nuvem, entre os quais se destacam o PlayFab (comprado pela Microsoft) e GameSparks (comprado pela Amazon).
Em geral, eles são funcionalmente semelhantes e cobrem necessidades básicas. Além disso, sua arquitetura interna é muito diferente, as mesmas tarefas são resolvidas de maneira um pouco diferente e as correspondências explícitas nos recursos são difíceis de rastrear. Abaixo estão os recursos positivos e negativos de cada plataforma e considerações sobre o tema da escolha.
Playfab
Características positivas:
- Contas de jogos diferentes são combinadas em uma conta principal
- A economia dos jogos é descrita sem uma única linha de código, incluindo preços para uma loja virtual separada
- Interface de usuário amigável
- Microsoft adquire produto após aquisição
- O custo de propriedade na produção pela assinatura do Indie Studio é de US $ 99 (até 100k MAU). Ao mudar para o Professional, uma assinatura de 1k MAU custará US $ 8 (conta mínima US $ 300)
Recursos negativos:
- O armazenamento de dados do jogo é estritamente limitado, por exemplo, em uma assinatura gratuita para armazenar dados para uma sessão específica do jogo (se eu entendi tudo corretamente, os Grupos de Entidades são usados para isso) estão disponíveis 3 slots de 500 bytes cada
- Para organizar o Multiplayer, você precisa conectar servidores de terceiros que processarão eventos dos clientes e calcularão a lógica do jogo. Esse é o Photon no seu hardware ou o Azure Thunderhead, e você precisa não apenas organizar o servidor, mas também atualizar sua assinatura para pelo menos o Indie Studio
- É necessário aturar o fato de que o código da nuvem sem preenchimento automático e não há como invadir os módulos (ou não encontrou?)
- Não há depurador normal, você só pode gravar logs no CloudScript e visualizar
Gamesparks
Características positivas:
- Armazenamento de dados do jogo. Não apenas existem muitos lugares onde você pode salvar dados (metadados gerais do jogo, mercadorias virtuais, perfil do jogador, sessões multiplayer etc.), a plataforma também fornece um banco de dados como serviço completo que não está vinculado a nada, além disso, o MongoDB e o Redis estão disponíveis imediatamente para diferentes tipos de dados. No ambiente de desenvolvimento, você pode armazenar 10 MB, na batalha 10 GB
- O multiplayer está disponível em uma assinatura gratuita (Desenvolvimento) com um limite de 10 conexões simultâneas e 10 solicitações por segundo
- Trabalho conveniente com o CloudCode, incluindo uma ferramenta integrada para teste e depuração (Test Harness)
Recursos negativos:
- A sensação de que desde a compra pela Amazon (inverno 2018) a ferramenta estagnou, não há inovações
- Novamente, após a aquisição da Amazon, as tarifas pioraram; anteriormente era possível usar até 10.000 MAU na produção gratuitamente
- O custo de propriedade de produção começa em US $ 300 (assinatura padrão)
Reflexões
Primeiro você tem que verificar o conceito do jogo. Para fazer isso, quero construir um protótipo de paus e fita adesiva sem investimentos monetários e iniciar testes de mecânica de jogos. Portanto, em primeiro lugar ao escolher, levanto a oportunidade de desenvolver e testar um mecânico em uma assinatura gratuita.
A GameSparks atende a esse critério, mas o PlayFab não, porque você precisará de um servidor que lide com os eventos dos clientes de jogos e uma assinatura Indie em nível de estúdio (US $ 99).
Ao mesmo tempo, aceito o risco de a Amazon não desenvolver o GameSparks, o que significa que ele pode "morrer". Dado isso e ainda o custo de propriedade na produção, tenho em mente a necessidade potencial de mudar para outra plataforma ou para meu próprio back-end.
Primeiros passos no desenvolvimento
Conexão e autenticação
Portanto, a escolha recaiu sobre a GameSparks como back-end na fase de prototipagem. O primeiro passo é aprender a conectar-se à plataforma e autenticar o player. Um ponto importante é que o usuário poderá jogar sem registro e SMS imediatamente após a instalação do jogo. Para fazer isso, a GameSparks oferece a opção de criar um perfil anônimo chamando o método DeviceAuthenticationRequest; posteriormente, com base em um perfil anônimo, você pode criar um perfil completo conectando, por exemplo, à sua conta do Google.
Dado que tenho um TDD cerebral, comecei criando um teste para conectar o cliente ao jogo. Como no futuro o CloudCode precisará ser gravado em JS, farei testes de integração em JS usando mocha.js e chai.js. O primeiro teste foi assim:
var expect = require("chai").expect; var GameClientModule = require("../src/gameClient"); describe("Integration test", function () { this.timeout(0); it("should connect client to server", async function () { var gameClient = new GameClientModule.GameClient(); expect(gameClient.connected()).is.false; await gameClient.connect(); expect(gameClient.connected()).is.true; }); })
Por padrão, o tempo limite no mocha.js é de 2 segundos, eu o faço infinitamente imediatamente, porque os testes são de integração. No teste, crio um cliente de jogo que ainda não foi implementado, verifique se não há conexão com o servidor, chame o comando para conectar-se ao back-end e verifique se o cliente foi conectado com êxito.
Para que o teste fique verde, você precisa fazer o download e adicionar o GameSparks JS SDK ao projeto, além de conectar suas dependências (crypto-js e ws) e, é claro, implementar o GameClientModule:
var GameSparks = require("../gamesparks-javascript-sdk-2018-04-18/gamesparks-functions"); var config = new require("./config.json"); exports.GameClient = function () { var gamesparks = new GameSparks(); this.connected = () => (gamesparks.connected === true); this.connect = function () { return new Promise(function (resolve, reject) { gamesparks.initPreview({ key: config.gameApiKey, secret: config.credentialSecret, credential: config.credential, onInit: () => resolve(), onMessage: onMessage, onError: (error) => reject(error), logger: console.log }); }); } function onMessage(message) { console.log("GAME onMessage: " + JSON.stringify(message)); } }
Na implementação inicial do cliente do jogo, as chaves necessárias para a autorização técnica para criar uma conexão a partir do aplicativo cliente são lidas na configuração. O método conectado agrupa o mesmo campo do SDK. A coisa mais importante acontece no método connect, que retorna uma promessa com retornos de chamada para uma conexão ou erro bem-sucedido, também vincula o manipulador onMessage ao mesmo retorno de chamada. O onMessage atuará como o gerenciador de processamento de mensagens do back-end, por enquanto, permita que ele registre mensagens no console.
Parece que o trabalho está concluído, mas o teste permanece vermelho. Acontece que o GameSparks JS SDK não funciona com o node.js; para ele, falta o contexto do navegador. Vamos fazê-lo pensar que o nó é o Chrome na papoula. Vamos para gamesparks.js e no início adicionamos:
if (typeof module === 'object' && module.exports) {
O teste ficou verde, seguindo em frente.
Como escrevi anteriormente, um jogador deve poder começar a jogar imediatamente assim que entrar no jogo, enquanto eu quero começar a acumular análises em atividade. Para fazer isso, ligamos ao identificador do dispositivo ou a um identificador gerado aleatoriamente. Verifique se este será um teste:
it("should auth two anonymous players", async function () { var gameClient1 = new GameClientModule.GameClient(); expect(gameClient1.playerId).is.undefined; var gameClient2 = new GameClientModule.GameClient(); expect(gameClient2.playerId).is.undefined; await gameClient1.connect(); await gameClient1.authWithCustomId("111"); expect(gameClient1.playerId).is.equals("5b5f5614031f5bc44d59b6a9"); await gameClient2.connect(); await gameClient2.authWithCustomId("222"); expect(gameClient2.playerId).is.equals("5b5f6ddb031f5bc44d59b741"); });
Decidi verificar imediatamente dois clientes para garantir que cada cliente crie seu próprio perfil no back-end. Para fazer isso, o cliente do jogo precisará de um método no qual você possa transferir um determinado identificador externo ao GameSparks e, em seguida, verifique se o cliente entrou em contato com o perfil de jogador desejado. Perfis previamente preparados no portal GameSparks.
Para implementação no GameClient, adicione:
this.playerId = undefined; this.authWithCustomId = function (customId) { return new Promise(resolve => { var requestData = { "deviceId": customId , "deviceOS": "NodeJS" } sendRequest("DeviceAuthenticationRequest", requestData) .then(response => { if (response.userId) { this.playerId = response.userId; resolve(); } else { reject(new Error(response)); } }) .catch(error => { console.error(error); }); }); } function sendRequest(requestType, requestData) { return new Promise(function (resolve) { gamesparks.sendWithData(requestType, requestData, (response) => resolve(response)); }); }
A implementação se resume a enviar uma solicitação DeviceAuthenticationRequest, receber o identificador do jogador da resposta e colocá-lo na propriedade do cliente. Imediatamente, em um método separado, o auxiliar enviou solicitações ao GameSparks com um wrapper em uma promessa.
Ambos os testes são verdes, resta adicionar o fechamento da conexão e refatorar.
No GameClient, adicionei um método que fecha a conexão com o servidor (desconectar) e connectAsAnonymous combinando connect e authWithCustomId. Por um lado, o connectAsAnonymous viola o princípio da responsabilidade única, mas não parece violar ... Ao mesmo tempo, agrega usabilidade, porque em testes geralmente é necessário autenticar clientes. O que você acha disso?
Nos testes, ele adicionou um auxiliar de método de fábrica que cria uma nova instância do cliente do jogo e adiciona à matriz de clientes criados. No manipulador mocha especial, após cada teste em execução para clientes na matriz, chamo o método de desconexão e limpo essa matriz. Ainda não gosto de "strings mágicos" no código, então adicionei um dicionário com identificadores personalizados usados nos testes.
O código final pode ser visualizado no repositório, um link que fornecerei no final do artigo.
Organização de pesquisa de jogos (matchmaking)
Iniciarei o recurso de correspondência, que é muito importante para o multiplayer. Este sistema começa a funcionar quando pressionamos o botão "Encontrar um jogo" em um jogo. Ela pega rivais, colegas de equipe ou os dois (dependendo do jogo). Como regra, nesses sistemas, cada jogador possui um indicador numérico MMR (Match Making Ratio) - uma classificação pessoal do jogador, usada para selecionar outros jogadores com o mesmo nível de habilidade.
Para testar essa funcionalidade, vim com o seguinte teste:
it("should find match", async function () { var gameClient1 = newGameClient(); var gameClient2 = newGameClient(); var gameClient3 = newGameClient(); await gameClient1.connectAsAnonymous(playerCustomIds.id1); await gameClient2.connectAsAnonymous(playerCustomIds.id2); await gameClient3.connectAsAnonymous(playerCustomIds.id3); await gameClient1.findStandardMatch(); expect(gameClient1.state) .is.equals(GameClientModule.GameClientStates.MATCHMAKING); await gameClient2.findStandardMatch(); expect(gameClient2.state) .is.equals(GameClientModule.GameClientStates.MATCHMAKING); await gameClient3.findStandardMatch(); expect(gameClient3.state) .is.equals(GameClientModule.GameClientStates.MATCHMAKING); await sleep(3000); expect(gameClient1.state) .is.equals(GameClientModule.GameClientStates.CHALLENGE); expect(gameClient1.challenge, "challenge").is.not.undefined; expect(gameClient1.challenge.challengeId).is.not.undefined; expect(gameClient2.state) .is.equals(GameClientModule.GameClientStates.CHALLENGE); expect(gameClient2.challenge.challengeId) .is.equals(gameClient1.challenge.challengeId); expect(gameClient3.state) .is.equals(GameClientModule.GameClientStates.CHALLENGE); expect(gameClient3.challenge.challengeId) .is.equals(gameClient1.challenge.challengeId); });
Três clientes estão conectados ao jogo (no futuro, é um mínimo necessário para verificar alguns cenários) e são registrados para procurar o jogo. Após registrar o terceiro jogador no servidor, uma sessão de jogo é formada e os jogadores devem se conectar a ele. Ao mesmo tempo, o estado dos clientes muda e o contexto da sessão do jogo com o mesmo identificador é exibido.
Primeiro, prepare o back-end. No GameSparks, existe uma ferramenta pronta para personalizar a pesquisa de jogos, disponível no caminho “Configurator-> Matches”. Crio um novo e prossigo com a instalação. Além dos parâmetros padrão, como código, nome e descrição da partida, são indicados o número mínimo e máximo de jogadores necessários para um modo de jogo personalizado. Atribuirei o código "StandardMatch" à partida criada e indicarei o número de jogadores de 2 a 3.
Agora você precisa configurar as regras para selecionar jogadores na seção "Limiares". Para cada limite, são indicados o tempo de sua ação, tipo (absoluto, relativo e em porcentagem) e limites.

Suponha que um jogador com um MMR de 19 comece a pesquisar. No exemplo acima, os primeiros 10 segundos serão a seleção de outros jogadores com um MMR de 19 a 21. Se os jogadores não foram selecionados, a segunda borda de pesquisa será ativada, o que aumentará o intervalo de pesquisa de 16 pelos próximos 20 segundos ( 19-3) a 22 (19 + 3). Em seguida, é incluído o terceiro limite, no qual uma pesquisa será realizada por 30 segundos no intervalo de 14 (19-25%) a 29 (19 + 50%), enquanto a partida será considerada concluída se o número mínimo necessário de jogadores tiver sido acumulado (Aceitar nota mínima Jogadores).
De fato, o mecanismo é mais complicado, pois leva em consideração o MMR de todos os jogadores que conseguiram participar de uma partida específica. Analisarei esses detalhes quando chegar a hora de criar o modo de classificação do jogo (não neste artigo). Para o modo de jogo padrão, em que ainda não planejo usar o MMR, preciso de apenas um limite de qualquer tipo.
Quando todos os jogadores tiverem sido selecionados, você precisará criar uma sessão de jogo e conectar jogadores a ela. No GameSparks, a função de sessão do jogo é o "Desafio". Como parte dessa entidade, os dados da sessão do jogo são armazenados e as mensagens são trocadas entre os clientes do jogo. Para criar um novo tipo de desafio, você precisa seguir o caminho "Configurador-> Desafios". Lá, adiciono um novo tipo com o código "StandardChallenge" e indico que esse tipo de sessão de jogo é baseado em turnos, ou seja, jogadores se revezam, não simultaneamente. A GameSparks ao mesmo tempo assume o controle da sequência de jogadas.
Para que um cliente se registre para procurar um jogo, você pode usar uma solicitação do tipo MatchmakingRequest, mas eu não a recomendaria, porque o valor MMR do jogador é necessário como um dos parâmetros. Isso pode levar a fraudes por parte do cliente do jogo, e o cliente não deve conhecer nenhum MMR; esse é um negócio de back-end. Para me registrar corretamente na pesquisa do jogo, eu crio um evento arbitrário do cliente. Isso é feito na seção "Configurador-> Eventos". Eu chamo o evento FindStandardMatch sem atributos. Agora você precisa configurar a reação a esse evento. Para isso, vou para a seção "Configurator-> Cloud Code" do código da nuvem, escrevo o seguinte manipulador para FindStandardMatch na seção "Events":
var matchRequest = new SparkRequests.MatchmakingRequest(); matchRequest.matchShortCode = "StandardMatch"; matchRequest.skill = 0; matchRequest.Execute();
Esse código registra um jogador no StandardMatch com um MMR de 0; portanto, qualquer jogador registrado para procurar um jogo padrão será adequado para criar uma sessão de jogo. Na seleção de uma partida de classificação, pode haver um apelo aos dados privados do perfil do jogador para obter a MMR desse tipo de partida.
Quando houver jogadores suficientes para iniciar uma sessão de jogo, o GameSparks enviará uma mensagem MatchFoundMessage a todos os jogadores selecionados. Aqui você pode gerar automaticamente uma sessão do jogo e adicionar jogadores a ela. Para fazer isso, em "Mensagens do usuário-> MatchFoundMessage", adicione o código:
var matchData = Spark.getData(); if (Spark.getPlayer().getPlayerId() != matchData.participants[0].id) { Spark.exit(); } var challengeCode = ""; var accessType = "PRIVATE"; switch (matchData.matchShortCode) { case "StandardMatch": challengeCode = "StandardChallenge"; break; default: Spark.exit(); } var createChallengeRequest = new SparkRequests.CreateChallengeRequest(); createChallengeRequest.challengeShortCode = challengeCode; createChallengeRequest.accessType = accessType; var tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); createChallengeRequest.endTime = tomorrow.toISOString(); createChallengeRequest.usersToChallenge = []; var participants = matchData.participants; var numberOfPlayers = participants.length; for (var i = 1; i < numberOfPlayers; i++) { createChallengeRequest.usersToChallenge.push(participants[i].id) } createChallengeRequest.Send();
O código primeiro verifica se é o primeiro jogador na lista de participantes. Em seguida, em nome do primeiro jogador, uma instância do StandardChallenge é criada e os jogadores restantes são convidados. Os jogadores convidados recebem uma mensagem ChallengeIssuedMessage. Aqui você pode visualizar o comportamento quando um convite para ingressar no jogo for exibido no cliente e exigir confirmação enviando AcceptChallengeRequest, ou você poderá aceitar o convite no modo silencioso. Então, farei isso. Para isso, em "Mensagens do usuário-> ChallengeIssuedMessage", adicionarei o seguinte código:
var challangeData = Spark.getData(); var acceptChallengeRequest = new SparkRequests.AcceptChallengeRequest(); acceptChallengeRequest.challengeInstanceId = challangeData.challenge.challengeId; acceptChallengeRequest.message = "Joining"; acceptChallengeRequest.SendAs(Spark.getPlayer().getPlayerId());
A próxima etapa, a GameSparks despacha o evento ChallengeStartedMessage. O manipulador global deste evento ("Mensagens Globais-> ChallengeStartedMessage") é o local ideal para inicializar uma sessão de jogo. Eu cuidarei disso ao implementar a lógica do jogo.
Chegou a hora do aplicativo cliente. Alterações no módulo do cliente:
exports.GameClientStates = { IDLE: "Idle", MATCHMAKING: "Matchmaking", CHALLENGE: "Challenge" } exports.GameClient = function () { this.state = exports.GameClientStates.IDLE; this.challenge = undefined; function onMessage(message) { switch (message["@class"]) { case ".MatchNotFoundMessage": this.state = exports.GameClientStates.IDLE; break; case ".ChallengeStartedMessage": this.state = exports.GameClientStates.CHALLENGE; this.challenge = message.challenge; break; default: console.log("GAME onMessage: " + JSON.stringify(message)); } } onMessage = onMessage.bind(this); this.findStandardMatch = function () { var eventData = { eventKey: "FindStandardMatch" } return new Promise(resolve => { sendRequest("LogEventRequest", eventData) .then(response => { if (!response.error) { this.state = exports.GameClientStates.MATCHMAKING; resolve(); } else { console.error(response.error); reject(new Error(response)); } }) .catch(error => { console.error(error); reject(new Error(error)); }); }); } }
De acordo com o teste, alguns campos apareceram no cliente - estado e desafio. O método onMessage adquiriu uma aparência significativa e agora responde a mensagens sobre o início de uma sessão de jogo e a uma mensagem de que não foi possível comprar um jogo. O método findStandardMatch também foi adicionado, que envia a solicitação correspondente ao back-end. O teste é verde, mas estou satisfeito, com a seleção de jogos dominada.
O que vem a seguir?
Nos artigos a seguir, descreverei o processo de desenvolvimento da lógica do jogo, desde a inicialização de uma sessão do jogo até o processamento de movimentos. Analisarei os recursos de armazenamento de diferentes tipos de dados: uma descrição dos metadados do jogo, características do mundo do jogo, dados das sessões do jogo e dados sobre os jogadores. A lógica do jogo será desenvolvida através de dois tipos de teste - unidade e integração.
Carregarei as fontes no github em partes vinculadas a artigos.
Há um entendimento de que, para avançar efetivamente na criação de um jogo, você precisa expandir nossa equipe de entusiastas. O artista / designer entrará em breve. E o guru do Unity3D, por exemplo, que abrirá as portas para as plataformas móveis, ainda não foi encontrado.