Criação de um jogo de navegador multiplayer no estilo .io

Criação de um jogo de navegador multiplayer no estilo .io
Lançado em 2015 Agar.io foi o precursor de um novo gênero de jogos .io, cuja popularidade cresceu muito desde então. Experimentei o aumento da popularidade dos jogos .io em primeira mão: nos últimos três anos, eu criei e vendi dois jogos desse gênero..

Caso você nunca tenha ouvido falar desses jogos: são jogos gratuitos multiplayer na web que são fáceis de participar (não requerem conta). Normalmente, eles colocam muitos jogadores em uma única arena. Outros jogos famosos do gênero .io: Slither.io e Diep.io.

Neste post, vamos explorar como criar um jogo .io do zero. Para isso, basta ter conhecimento em Javascript: você precisa entender coisas como a sintaxe ES6, palavra-chave this e Promises. Mesmo que você não saiba Javascript perfeitamente, ainda conseguirá entender a maior parte do post.

Exemplo de jogo .io

Para ajudar no aprendizado, nos referiremos ao exemplo de jogo .io. Experimente jogar!

Criação de um jogo de navegador multiplayer no estilo .io
O jogo é bastante simples: você controla uma nave na arena, onde há outros jogadores. Sua nave atira automaticamente projéteis e você tenta atingir outros jogadores, enquanto evita seus projéteis.

1. Visão geral/estrutura do projeto

Recomendo baixar o código fonte do exemplo do jogo, para que você possa me acompanhar.

No exemplo, é utilizado o seguinte:

  • Express — o framework web mais popular para Node.js, que gerencia o servidor web do jogo.
  • socket.io — biblioteca websocket para troca de dados entre o navegador e o servidor.
  • Webpack — gerenciador de módulos. Sobre porque usar o Webpack, você pode ler aqui.

Veja como é a estrutura do diretório do projeto:

public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

public/

Tudo na pasta public/ será entregue estaticamente pelo servidor. No public/assets/ estão as imagens utilizadas pelo nosso projeto.

src/

Todo o código fonte está na pasta src/. Os nomes client/ e server/ são autoexplicativos, e shared/ contém um arquivo de constantes importado tanto pelo cliente quanto pelo servidor.

2. Builds/parâmetros do projeto

Como mencionado anteriormente, para construir o projeto usamos o gerenciador de módulos Webpack. Vamos dar uma olhada na nossa configuração do Webpack:

webpack.common.js:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    game: '. /src/client/index.js',
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/, 
        use: {
          loader: "babel-loader",
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src/client/html/index.html',
    }),
  ],
};

As partes mais importantes aqui são as seguintes linhas:

  • src/client/index.js — este é o ponto de entrada do cliente Javascript (JS). O Webpack começará daqui e buscará recursivamente outros arquivos importados.
  • O JS de saída da nossa construção Webpack estará localizado no diretório dist/. Eu chamarei este arquivo de nosso pacote JS.
  • Nós usamos Babel, e em particular a configuração @babel/preset-env para transpilar nosso código JS para navegadores mais antigos.
  • Usamos um plugin para extrair todo CSS referenciado pelos arquivos JS e para combiná-los em um só lugar. Eu chamarei isso de nosso pacote CSS.

Você pode ter notado nomes estranhos para os arquivos de pacotes '[name].[contenthash].ext'. Eles contêm substituições de nomes de arquivos Webpack: [name] será substituído pelo nome do ponto de entrada (neste caso é game), e [contenthash] será substituído pelo hash do conteúdo do arquivo. Fazemos isso para otimizar o projeto para hash — podemos instruir os navegadores a armazenar em cache indefinidamente nossos pacotes JS, porque se o pacote mudar, o nome do arquivo também mudará (o contenthash). O resultado final será um nome de arquivo como game.dbeee76e91a97d0c7207.js.

Arquivo webpack.common.js — este é o arquivo de configuração base que importamos nas configurações de desenvolvimento e no projeto final. Aqui está, por exemplo, a configuração de desenvolvimento:

webpack.dev.js

const merge = require('webpack-merge');
const common = require('. /webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

Para eficiência, usamos durante o desenvolvimento webpack.dev.js, e muda para webpack.prod.js, para otimizar o tamanho dos pacotes ao implantar em produção.

Configuração local

Recomendo instalar o projeto na máquina local, para que você possa seguir os passos listados neste post. A configuração é simples: primeiro, o sistema deve ter instalado Nó e NPM. Em seguida, você deve executar

$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install

e você está pronto para começar! Para iniciar o servidor de desenvolvimento, basta executar

$ npm run develop

e acessar no navegador da web localhost:3000. O servidor de desenvolvimento irá recompilar automaticamente os pacotes JS e CSS à medida que o código muda — basta atualizar a página para ver todas as alterações!

3. Pontos de entrada do cliente

Vamos começar com o código do jogo. Para começar, precisaremos de uma página index.html, que será carregada primeiro ao visitar o site. Nossa página será bem simples:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Um exemplo de jogo .io</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Nome de usuário" />
    <button id="play-button">JOGAR</button>
  </div>
</body>
</html>

Este exemplo de código é levemente simplificado para melhor compreensão, o mesmo farei com muitos outros exemplos do post. O código completo pode ser visualizado em Github.

Temos:

  • Elemento HTML5 Canvas (<canvas>), que usaremos para renderizar o jogo.
  • <link> para adicionar nosso pacote CSS.
  • <script> para adicionar nosso pacote Javascript.
  • Menu principal com nome de usuário <input> e um botão 'PLAY' (

Após carregar a página inicial no navegador, o código Javascript começará a ser executado, começando com o arquivo JS de entrada: src/client/index.js.

index.js

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Isso pode parecer complicado, mas na verdade aqui não há muitas ações acontecendo:

  1. Importando vários outros arquivos JS.
  2. Importando CSS (para que o Webpack saiba que precisa incluí-los em nosso pacote CSS).
  3. Iniciar connect() para estabelecer uma conexão com o servidor e iniciar downloadAssets() para baixar imagens necessárias para renderizar o jogo.
  4. Após concluir a etapa 3 o menu principal é exibido (playMenu).
  5. Configurando o manipulador de eventos do botão 'PLAY'. Quando o botão é clicado, o código inicializa o jogo e informa ao servidor que estamos prontos para jogar.

A parte principal da nossa lógica cliente-servidor está nos arquivos que foram importados pelo arquivo index.js. Agora vamos examiná-los todos na ordem.

4. Troca de dados do cliente

Neste jogo, usamos uma biblioteca bem conhecida para nos comunicar com o servidor socket.io. O Socket.io tem suporte embutido WebSockets, que são ótimos para comunicação bidirecional: podemos enviar mensagens ao servidor e e o servidor pode nos enviar mensagens pela mesma conexão.

Teremos um arquivo src/client/networking.js, que cuidará de todas as comunicações com o servidor:

networking.js

import io from 'socket.io-client';
import { processGameUpdate } from '.\/state';

const Constants = require('..\/shared\/constants');

const socket = io(`ws:\/\/${window.location.host}`);
const connectedPromise = new Promise(resolve => {
  socket.on('connect', () => {
    console.log('Conectado ao servidor!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    \/\/ Registrar callbacks
    socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
    socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
  })
);

export const play = username => {
  socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};

export const updateDirection = dir => {
  socket.emit(Constants.MSG_TYPES.INPUT, dir);
};

Este código também foi ligeiramente abreviado para clareza.

Neste arquivo, ocorrem três ações principais:

  • Tentamos conectar ao servidor. connectedPromise só é resolvido quando conseguimos estabelecer a conexão.
  • Se a conexão for estabelecida com sucesso, registramos as funções de callback (processGameUpdate() e onGameOver()) para as mensagens que podemos receber do servidor.
  • Exportamos play() e updateDirection(), para que outros arquivos possam usá-los.

5. Renderização do cliente

Chegou a hora de mostrar a imagem na tela!

…mas antes de fazermos isso, precisamos baixar todas as imagens (recursos) que são necessárias para isso. Vamos escrever um gerenciador de recursos:

assets.js

const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Baixado ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `\/assets\/${assetName}`;
  });
}

export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];

Gerenciar recursos não é tão difícil! A ideia principal é armazenar um objeto assets, que vinculará a chave do nome do arquivo ao valor do objeto Imagem. Quando o recurso for carregado, o salvamos no objeto assets para acesso rápido no futuro. Quando o download de cada recurso individual for permitido (ou seja, quando os tudo recursos forem baixados), liberamos downloadPromise.

Após baixar os recursos, podemos começar a renderizar. Como mencionado anteriormente, usamos HTML5 Canvas (<canvas>). Nosso jogo é bastante simples, portanto, basta renderizar o seguinte:

  1. Fundo
  2. Nave do jogador
  3. Outros jogadores em jogo
  4. Projéteis

Aqui estão os fragmentos importantes src/client/render.js, que renderizam exatamente os quatro itens listados acima:

render.js

import { getAsset } from './assets';
import { getCurrentState } from './state';

const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;

// Obtem o contexto gráfico do canvas
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Faz o canvas em tela cheia
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

function render() {
  const { me, others, bullets } = getCurrentState();
  if (!me) {
    return;
  }

  // Desenha o fundo
  renderBackground(me.x, me.y);

  // Desenha todos os projéteis
  bullets.forEach(renderBullet.bind(null, me));

  // Desenha todos os jogadores
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Funções auxiliares aqui excluídas

let renderInterval = null;
export function startRendering() {
  renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
  clearInterval(renderInterval);
}

Este código também foi abreviado para clareza.

render() — é a função principal deste arquivo. startRendering() e stopRendering() controlam a ativação do ciclo de renderização a uma taxa de 60 FPS.

Implementações específicas de funções auxiliares de renderização (por exemplo renderBullet()) não são tão importantes, mas aqui está um exemplo simples:

render.js

function renderBullet(me, bullet) {
  const { x, y } = bullet;
  context.drawImage(
    getAsset('bullet.svg'),
    canvas.width / 2 + x - me.x - BULLET_RADIUS,
    canvas.height / 2 + y - me.y - BULLET_RADIUS,
    BULLET_RADIUS * 2,
    BULLET_RADIUS * 2,
  );
}

Note que estamos usando o método getAsset(), que vimos anteriormente em asset.js!

Se você está interessado em explorar outras funções auxiliares de renderização, leia o restante de src/client/render.js.

6. Entrada do cliente

É hora de tornar o jogo jogável! O esquema de controle será muito simples: para mudar a direção do movimento, você pode usar o mouse (no computador) ou tocar na tela (no dispositivo móvel). Para implementar isso, registraremos Event Listeners para eventos de Mouse e Touch.
Tudo isso será feito por src/client/input.js:

input.js

import { updateDirection } from './networking';

function onMouseInput(e) {
  handleInput(e.clientX, e.clientY);
}

function onTouchInput(e) {
  const touch = e.touches[0];
  handleInput(touch.clientX, touch.clientY);
}

function handleInput(x, y) {
  const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
  updateDirection(dir);
}

export function startCapturingInput() {
  window.addEventListener('mousemove', onMouseInput);
  window.addEventListener('touchmove', onTouchInput);
}

export function stopCapturingInput() {
  window.removeEventListener('mousemove', onMouseInput);
  window.removeEventListener('touchmove', onTouchInput);
}

onMouseInput() e onTouchInput() — são os Event Listeners que chamam updateDirection() (de networking.js) quando um evento de entrada ocorre (por exemplo, ao mover o mouse). updateDirection() se comunica com o servidor que processa o evento de entrada e atualiza o estado do jogo de acordo.

7. Estado do cliente

Esta seção é a mais complexa da primeira parte do post. Não desanime se não entender na primeira leitura! Você pode até pular e voltar a ela mais tarde.

O último pedaço do quebra-cabeça necessário para completar o código cliente-servidor é state. Lembre-se do fragmento de código da seção "Renderização do Cliente"?

render.js

import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Realiza a renderização
  // ...
}

getCurrentState() deve ser capaz de nos fornecer o estado atual do jogo no cliente a qualquer momento com base nas atualizações recebidas do servidor. Aqui está um exemplo de atualização do jogo que o servidor pode enviar:

{
  "t": 1555960373725,
  "me": {
    "x": 2213.8050880413657,
    "y": 1469.370893425012,
    "direction": 1.3082443894581433,
    "id": "AhzgAtklgo2FJvwWAADO",
    "hp": 100
  },
  "others": [],
  "bullets": [
    {
      "id": "RUJfJ8Y18n",
      "x": 2354.029197099604,
      "y": 1431.6848318262666
    },
    {
      "id": "ctg5rht5s",
      "x": 2260.546457727445,
      "y": 1456.8088728920968
    }
  ],
  "leaderboard": [
    {
      "username": "Player",
      "score": 3
    }
  ]
}

Cada atualização do jogo contém cinco campos idênticos:

  • t: timestamp do servidor, indicando o momento da criação desta atualização.
  • me: informações sobre o jogador que recebe esta atualização.
  • others: um array de informações sobre outros jogadores participando do mesmo jogo.
  • bullets: um array de informações sobre os projéteis no jogo.
  • leaderboard: dados atuais da tabela de líderes. Neste post, não os consideraremos.

7.1 Estado ingênuo do cliente

Implementação ingênua getCurrentState() pode apenas retornar diretamente os dados da última atualização recebida do jogo.

naive-state.js

let lastGameUpdate = null;

// Lida com uma nova atualização de jogo recebida.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Bonito e claro! Mas se tudo fosse assim tão simples. Uma das razões pelas quais tal implementação é problemática: ela limita a taxa de quadros de renderização à taxa de pulsos do servidor.

Taxa de Quadros (Frame Rate): número de quadros (ou seja, chamadas render()) por segundo, ou FPS. Nos jogos, geralmente buscamos alcançar pelo menos 60 FPS.

Taxa de Pulsos (Tick Rate): a frequência com que o servidor envia atualizações do jogo para os clientes. Frequentemente, é inferior à taxa de quadros. Em nosso jogo, o servidor opera a 30 pulsos por segundo.

Se apenas renderizarmos a última atualização do jogo, o FPS nunca poderá ultrapassar 30, porque nunca recebemos mais de 30 atualizações do servidor por segundo.Mesmo que chamemos render() 60 vezes por segundo, metade dessas chamadas apenas redesenhará a mesma coisa, essencialmente não fazendo nada. Outro problema da implementação ingênua é que ela é suscetível a atrasos.Com a velocidade ideal da Internet, o cliente receberá a atualização do jogo a cada 33 ms (30 por segundo):

Criação de um jogo de navegador multiplayer no estilo .io
Infelizmente, nada é perfeito. Um cenário mais realista seria:
Criação de um jogo de navegador multiplayer no estilo .io
A implementação ingênua é praticamente o pior caso quando se trata de atrasos. Se a atualização do jogo é recebida com um atraso de 50 ms, então o cliente fica atrasado em mais 50 ms, porque ainda está renderizando o estado do jogo da atualização anterior. Pode-se imaginar o quão incômodo isso é para o jogador: devido a atrasos aleatórios, o jogo parecerá truncado e instável.

7.2 Estado do cliente melhorado

Faremos algumas melhorias na implementação ingênua. Primeiro, usaremos um atraso de renderização de 100 ms. Isso significa que o estado 'atual' do cliente sempre estará 100 ms atrás do estado do jogo no servidor. Por exemplo, se no servidor o tempo é 150, então no cliente será renderizado o estado em que o servidor estava durante 50:

Criação de um jogo de navegador multiplayer no estilo .io
Isso nos dá um buffer de 100 ms, permitindo suportar o tempo imprevisível para receber as atualizações do jogo:

Criação de um jogo de navegador multiplayer no estilo .io
O preço a pagar por isso será uma atraso de entrada (input lag) de 100 ms. Esse é um sacrifício pequeno por um fluxo de jogo suave — a maioria dos jogadores (especialmente os casuais) nem perceberá esse atraso. As pessoas se adaptam muito mais facilmente a um atraso constante de 100 ms do que a um atraso imprevisível.

Podemos usar outra técnica chamada ‘previsão do lado do cliente’, que lida bem com a redução de latências percebidas, mas não será abordada neste post.

Outra melhoria que usamos é a interpolação linear.Devido ao atraso de renderização, geralmente estamos pelo menos uma atualização à frente do tempo atual no cliente. Quando chamamos getCurrentState(), podemos realizar a interpolação linear. entre atualizações do jogo imediatamente antes e depois do tempo atual no cliente:

Criação de um jogo de navegador multiplayer no estilo .io
Isso resolve o problema de taxa de quadros: agora podemos renderizar quadros únicos com qualquer taxa que precisamos!

7.3 Implementação do estado melhorado do cliente

Exemplo de implementação em src/client/state.js usa tanto o atraso de renderização quanto a interpolação linear, mas isso é por um curto período. Vamos dividir o código em duas partes. Aqui está a primeira:

state.js, parte 1

const RENDER_DELAY = 100;

const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;

export function initState() {
  gameStart = 0;
  firstServerTimestamp = 0;
}

export function processGameUpdate(update) {
  if (!firstServerTimestamp) {
    firstServerTimestamp = update.t;
    gameStart = Date.now();
  }
  gameUpdates.push(update);

  // Mantenha apenas uma atualização de jogo antes do tempo atual do servidor
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Retorna o índice da atualização base, a primeira atualização de jogo antes
// do tempo atual do servidor, ou -1 se N/A.
function getBaseUpdate() {
  const serverTime = currentServerTime();
  for (let i = gameUpdates.length - 1; i >= 0; i--) {
    if (gameUpdates[i].t <= serverTime) {
      return i;
    }
  }
  return -1;
}

Primeiro, precisamos entender o que faz currentServerTime(). Como vimos antes, cada atualização de jogo inclui um timestamp do servidor. Queremos usar o atraso de renderização para renderizar a imagem, atrasando em relação ao servidor em 100 ms, mas nunca saberemos qual é o tempo atual no servidor, porque não podemos saber quanto tempo qualquer uma das atualizações levou para chegar até nós. A internet é imprevisível e sua velocidade pode variar bastante!

Para contornar esse problema, podemos usar uma aproximação razoável: vamos fingir que a primeira atualização chegou instantaneamente. Se isso fosse verdade, saberíamos o tempo do servidor nesse momento específico! Mantemos o timestamp do servidor em firstServerTimestamp e salvamos nosso timestamp local (do cliente) no mesmo momento em.

gameStart Oh, espere. Não deveria ser o tempo do servidor = tempo do cliente? Por que estamos diferenciando "timestamp do servidor" e "timestamp do cliente"? Essa é uma ótima pergunta! Acontece que não é a mesma coisa. Date.now() retornará diferentes timestamps no cliente e no servidor, e isso depende de fatores locais dessas máquinas.

Nunca assuma que os timestamps serão iguais em todas as máquinas. currentServerTime()Agora entendemos o que faz : ele retorna o timestamp do servidor para o tempo atual de renderização. Em outras palavras, este é o tempo atual do servidor (firstServerTimestamp <+ (Date.now() - gameStart)) menos o atraso de renderização (RENDER_DELAY).

Agora, vamos entender como processamos as atualizações do jogo. Ao receber uma atualização do servidor, chamamos processGameUpdate(), e salvamos a nova atualização em um array gameUpdates. Então, para verificar o uso da memória, removemos todas as atualizações antigas até a atualização base, porque não precisamos mais delas.

O que é uma "atualização base"? É a primeira atualização que encontramos, movendo-nos para trás a partir do tempo atual do servidor. Lembra-se desse esquema?

Criação de um jogo de navegador multiplayer no estilo .io
A atualização do jogo logo à esquerda do "Tempo de Renderização do Cliente" é a atualização base.

Para que serve a atualização base? Por que podemos descartar atualizações até a base? Para entender isso, vamos finalmente analisar a implementação getCurrentState():

state.js, parte 2

export function getCurrentState() {
  if (!firstServerTimestamp) {
    return {};
  }

  const base = getBaseUpdate();
  const serverTime = currentServerTime();

  // If base is the most recent update we have, use its state.
  // Else, interpolate between its state and the state of (base + 1).
  if (base < 0) {
    return gameUpdates[gameUpdates.length - 1];
  } else if (base === gameUpdates.length - 1) {
    return gameUpdates[base];
  } else {
    const baseUpdate = gameUpdates[base];
    const next = gameUpdates[base + 1];
    const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
    return {
      me: interpolateObject(baseUpdate.me, next.me, r),
      others: interpolateObjectArray(baseUpdate.others, next.others, r),
      bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
    };
  }
}

Nós tratamos três casos:

  1. base < 0 significa que não há atualizações antes do tempo de renderização atual (veja a implementação acima de getBaseUpdate()). Isso pode acontecer logo no início do jogo devido ao atraso de renderização. Nesse caso, usamos a atualização mais recente recebida.
  2. base é a atualização mais recente que temos. Isso pode ocorrer devido a latência na rede ou uma conexão com a Internet ruim. Nesse caso, também usamos a atualização mais recente que temos.
  3. Temos atualizações antes e depois do tempo de renderização atual, então podemos interpolar!

Tudo o que resta em state.js é a implementação de interpolação linear, que consiste em matemática simples (mas chata). Se você quiser estudá-la por conta própria, abra state.js para Github.

Parte 2. Servidor Backend

Nesta parte, iremos analisar o backend em Node.js que gerencia nosso exemplo de jogo .io.

1. Ponto de entrada do servidor

Para gerenciar o servidor web, usaremos um popular framework web para Node.js chamado Express. Sua configuração será feita pelo nosso arquivo de ponto de entrada do servidor src/server/server.js:

server.js, parte 1

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');

// Configurar um servidor Express
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Configurar o Webpack para desenvolvimento
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Servir estaticamente a pasta dist/ em produção
  app.use(express.static('dist'));
}

// Ouvir na porta
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Servidor ouvindo na porta ${port}`);

Lembre-se de que na primeira parte discutimos o Webpack? É aqui que vamos usar nossas configurações do Webpack. Vamos aplicá-las de duas maneiras:

  • Usar webpack-dev-middleware para a recompilação automática de nossos pacotes de desenvolvimento, ou
  • Servir estaticamente a pasta dist/, na qual o Webpack irá gravar nossos arquivos após a compilação de produção.

Outra tarefa importante server.js é configurar o servidor socket.io, que se conecta simplesmente ao servidor Express:

server.js, parte 2

const socketio = require('socket.io');
const Constants = require('../shared/constants');

// Configurar o Express
// ...
const server = app.listen(port);
console.log(`Servidor ouvindo na porta ${port}`);

// Configurar socket.io
const io = socketio(server);

// Ouvir conexões socket.io
io.on('connection', socket => {
  console.log('Jogador conectado!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

Após a conexão socket.io ser estabelecida com o servidor, configuramos manipuladores de eventos para o novo socket. Os manipuladores de eventos processam as mensagens recebidas dos clientes delegando ao objeto singleton game:

server.js, parte 3

const Game = require('./game');

// ...

// Configurar o Jogo
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

Estamos criando um jogo do tipo .io, então precisamos apenas de uma instância Game ("Game") – todos os jogadores jogam em uma única arena! Na próxima seção veremos como funciona esta classe Game.

2. Servidor Game

Classe Game contém a lógica mais importante do lado do servidor. Ele tem duas tarefas principais: gerenciamento de jogadores e simulação de jogo.

Vamos começar pela primeira tarefa – gerenciamento de jogadores.

game.js, parte 1

const Constants = require('..\/shared\/constants');
const Player = require('.\/player');

class Game {
  constructor() {
    this.sockets = {};
    this.players = {};
    this.bullets = [];
    this.lastUpdateTime = Date.now();
    this.shouldSendUpdate = false;
    setInterval(this.update.bind(this), 1000 \/ 60);
  }

  addPlayer(socket, username) {
    this.sockets[socket.id] = socket;

    // Gere uma posição para começar este jogador.
    const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    this.players[socket.id] = new Player(socket.id, username, x, y);
  }

  removePlayer(socket) {
    delete this.sockets[socket.id];
    delete this.players[socket.id];
  }

  handleInput(socket, dir) {
    if (this.players[socket.id]) {
      this.players[socket.id].setDirection(dir);
    }
  }

  // ...
}

Neste jogo, vamos identificar os jogadores pelo campo id do socket.socket.io (se você ficou confuso, volte para server.js). O Socket.io atribui a cada socket um único id, então não precisamos nos preocupar com isso. Eu vou chamá-lo de ID do jogador.

Tendo isso em mente, vamos examinar as variáveis de instância na classe Game:

  • sockets — este é um objeto que vincula o ID do jogador ao socket associado ao jogador. Ele nos permite acessar sockets em tempo constante pelo ID do jogador.
  • players — este é um objeto que vincula o ID do jogador ao objeto code>Player

bullets — este é um array de objetos Bullet, sem uma ordem definida.
lastUpdateTime — este é um timestamp do último momento de atualização do jogo. Em breve, veremos como ele é usado.
shouldSendUpdate — esta é uma variável auxiliar. Vamos ver seu uso em breve.
Métodos addPlayer(), removePlayer() e handleInput() não precisam de explicação, elas são usadas em server.js. Se você precisa relembrar, volte um pouco para cima.

A última linha constructor() inicia o ciclo de atualização do jogo (a 60 atualizações/segundo):

game.js, parte 2

const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');

class Game {
  \/\/ ...

  update() {
    \/\/ Calcule o tempo decorrido
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Atualiza cada projétil
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Destrói este projétil
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    \/\/ Atualiza cada jogador
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    \/\/ Aplica colisões, dá pontuação aos jogadores por acertar projéteis
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    \/\/ Verifica se algum jogador está morto
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp  {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  \/\/ ...
}

Método atualizar() contém, provavelmente, a parte mais importante da lógica do lado do servidor. Vamos listar na ordem tudo o que ele faz:

  1. Calcula quanto tempo dt passou desde a última atualizar().
  2. Atualiza cada projétil e os destrói, se necessário. Veremos a implementação dessa funcionalidade mais tarde. Por enquanto, basta saber que bullet.update() fundos! E ainda recebe elogios de 75-90% dos participantes, embora o desenvolvimento do projeto claramente tenha desacelerado. O mais surpreendente é que todos foram verdadeiro, se o projétil deve ser destruído (ele saiu dos limites da arena).
  3. Atualiza cada jogador e, se necessário, cria um projétil. Essa implementação também veremos depois — player.update() pode retornar um objeto Bullet.
  4. Verifica colisões entre projéteis e jogadores usando applyCollisions(), que retorna um array de projéteis que atingiram jogadores. Para cada projétil retornado, aumentamos a pontuação do jogador que o disparou (usando player.onDealtDamage()), e então removemos o projétil do array bullets.
  5. Notifica e destrói todos os jogadores mortos.
  6. Envia a todos os jogadores uma atualização do jogo a cada segunda vez que é chamado atualizar(). Isso nos ajuda a rastrear a variável auxiliar mencionada acima shouldSendUpdate. Como atualizar() é chamado 60 vezes/s, enviamos atualizações do jogo 30 vezes/s. Assim, frequência do clock do servidor é de 30 clocks/s (falamos sobre a frequência do clock na primeira parte).

Por que enviar atualizações do jogo apenas de vez em quando? ? Para economizar largura de banda. 30 atualizações do jogo por segundo é muito!

Por que então não chamá-lo atualizar() 30 vezes por segundo? Para melhorar a simulação do jogo. Quanto mais frequentemente é chamado atualizar(), mais precisa será a simulação do jogo. Mas não devemos exagerar na quantidade de chamadas atualizar(), pois isso é uma tarefa computacionalmente intensiva — 60 por segundo é mais que suficiente.

O restante da classe Game consiste em métodos auxiliares, utilizados em atualizar():

game.js, parte 3

class Game {
  // ...

  getLeaderboard() {
    return Object.values(this.players)
      .sort((p1, p2) => p2.score - p1.score)
      .slice(0, 5)
      .map(p => ({ username: p.username, score: Math.round(p.score) }));
  }

  createUpdate(player, leaderboard) {
    const nearbyPlayers = Object.values(this.players).filter(
      p => p !== player && p.distanceTo(player)  b.distanceTo(player)  p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() é bastante simples – ele classifica os jogadores pela quantidade de pontos, pega os cinco melhores e retorna para cada um o nome de usuário e a pontuação.

createUpdate() é usado em atualizar() para criar atualizações do jogo que são enviadas aos jogadores. Sua principal tarefa é chamar os métodos serializeForUpdate(), implementados para as classes Player e Bullet. Note que ele só transmite para cada jogador dados sobre jogadores próximos e projéteis – não há necessidade de transmitir informações sobre objetos do jogo que estão longe do jogador!

3. Objetos de jogo no servidor

Em nosso jogo, projéteis e jogadores são na verdade muito semelhantes: são objetos de jogo abstratos e móveis. Para aproveitar essa semelhança entre jogadores e projéteis, vamos começar implementando a classe básica Object:

object.js

class Object {
  constructor(id, x, y, dir, speed) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.direction = dir;
    this.speed = speed;
  }

  update(dt) {
    this.x += dt * this.speed * Math.sin(this.direction);
    this.y -= dt * this.speed * Math.cos(this.direction);
  }

  distanceTo(object) {
    const dx = this.x - object.x;
    const dy = this.y - object.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  setDirection(dir) {
    this.direction = dir;
  }

  serializeForUpdate() {
    return {
      id: this.id,
      x: this.x,
      y: this.y,
    };
  }
}

Aqui não acontece nada complicado. Esta classe será um bom ponto de partida para expansão. Vamos ver como a classe Bullet usa Object:

bullet.js

const shortid = require('shortid');
const ObjectClass = require('./object');
const Constants = require('../shared/constants');

class Bullet extends ObjectClass {
  constructor(parentID, x, y, dir) {
    super(shortid(), x, y, dir, Constants.BULLET_SPEED);
    this.parentID = parentID;
  }

  // Retorna verdadeiro se a bala deve ser destruída
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Implementação Bullet é muito curta! Adicionamos ao Object apenas as seguintes extensões:

  • Uso do pacote shortid para geração aleatória id de balas.
  • Adicionando o campo parentID, para que possamos rastrear o jogador que criou esta bala.
  • Adicionando um valor de retorno em atualizar(), que é igual a verdadeiro, se a bala estiver fora dos limites da arena (lembre-se, falamos sobre isso na seção anterior?).

Vamos seguir para Player:

player.js

const ObjectClass = require('./object');
const Bullet = require('./bullet');
const Constants = require('../shared/constants');

class Player extends ObjectClass {
  constructor(id, username, x, y) {
    super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED);
    this.username = username;
    this.hp = Constants.PLAYER_MAX_HP;
    this.fireCooldown = 0;
    this.score = 0;
  }

  // Retorna uma nova bala criada ou null.
  update(dt) {
    super.update(dt);

    // Atualiza a pontuação
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Garante que o jogador permaneça dentro dos limites
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Dispara uma bala, se necessário
    this.fireCooldown -= dt;
    if (this.fireCooldown <= 0) {
      this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
      return new Bullet(this.id, this.x, this.y, this.direction);
    }
    return null;
  }

  takeBulletDamage() {
    this.hp -= Constants.BULLET_DAMAGE;
  }

  onDealtDamage() {
    this.score += Constants.SCORE_BULLET_HIT;
  }

  serializeForUpdate() {
    return {
      ...(super.serializeForUpdate()),
      direction: this.direction,
      hp: this.hp,
    };
  }
}

Os jogadores são mais complexos do que as balas, portanto, nesta classe deve haver mais alguns campos. Seu método atualizar() realiza mais trabalho, especialmente retorna a bala recém-criada, se não restar fireCooldown (lembre-se, falamos sobre isso na seção anterior?). Também estende o método serializeForUpdate(), porque precisamos incluir no update do jogo campos adicionais para o jogador.

Ter uma classe base Object é um passo importante para evitar repetição de código. Por exemplo, sem a classe Object cada objeto de jogo teria que ter a mesma implementação distanceTo(), e sincronizar todos esses códigos repetidos em vários arquivos seria um pesadelo. Isso se torna especialmente importante para projetos grandes, quando a quantidade de classes de extensão aumenta. Object 4. Reconhecimento de colisões

4. Reconhecimento de colisões

A única coisa que nos resta é reconhecer quando os projéteis atingem os jogadores! Lembre-se deste fragmento de código do método atualizar() na classe Game:

game.js

const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // ...

    // Aplicar colisões, dar pontos aos jogadores por atingir os projéteis
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // ...
  }
}

Precisamos implementar o método applyCollisions(), que retorna todos os projéteis que atingiram os jogadores. Felizmente, isso não é tão difícil de fazer, porque

  • Todos os objetos em colisão são círculos, e essa é a forma mais simples de se implementar a detecção de colisões.
  • Já temos o método distanceTo(), que implementamos na seção anterior na classe Object.

Assim é a nossa implementação da detecção de colisões:

collisions.js

const Constants = require('../shared/constants');

// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
      ) {
        destroyedBullets.push(bullet);
        player.takeBulletDamage();
        break;
      }
    }
  }
  return destroyedBullets;
}

Esta detecção de colisões é baseada no fato de que dois círculos colidem se a distância entre seus centros for menor que a soma de seus raios.Aqui está um caso em que a distância entre os centros de dois círculos é exatamente igual à soma de seus raios:

Criação de um jogo de navegador multiplayer no estilo .io
Aqui precisamos prestar atenção a mais alguns aspectos:

  • Um projétil não deve atingir o jogador que o criou. Isso pode ser alcançado comparando bullet.parentID com player.id.
  • Um projétil deve atingir apenas uma vez em casos de colisão simultânea com vários jogadores. Vamos resolver isso com o operador break: assim que encontrarmos um jogador que colidiu com o projétil, pararemos a busca e iremos para o próximo projétil.

Fim

E é isso! Cobrimos tudo que você precisa saber para criar um jogo web do tipo .io. O que vem a seguir? Crie seu próprio jogo .io!

Todo o código do exemplo é de código aberto e está disponível em Github.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster