Criando um jogo web .io multijogador

Criando um jogo web .io multijogador
Lançado em 2015 Agar.io tornou-se o progenitor de um novo gênero jogos .ioque cresceu em popularidade desde então. Eu pessoalmente experimentei o aumento da popularidade dos jogos .io: nos últimos três anos, criou e vendeu dois jogos desse gênero..

Caso você nunca tenha ouvido falar desses jogos antes, eles são jogos multijogador gratuitos na web e fáceis de jogar (sem necessidade de conta). Eles geralmente enfrentam muitos jogadores adversários na mesma arena. Outros jogos .io famosos: Slither.io и Diep.io.

Nesta postagem, exploraremos como crie um jogo .io do zero. Para isso, apenas o conhecimento de Javascript será suficiente: você precisa entender coisas como sintaxe ES6palavra-chave this и Promessas. Mesmo que seu conhecimento de Javascript não seja perfeito, você ainda poderá entender a maior parte da postagem.

exemplo de jogo .io

Para assistência de aprendizagem, nos referiremos a exemplo de jogo .io. Tente jogar!

Criando um jogo web .io multijogador
O jogo é bastante simples: você controla uma nave em uma arena onde há outros jogadores. Sua nave dispara projéteis automaticamente e você tenta atingir outros jogadores enquanto evita seus projéteis.

1. Breve visão geral/estrutura do projeto

Recomendo baixar código fonte jogo de exemplo para que você possa me seguir.

O exemplo usa o seguinte:

  • Express é a estrutura web Node.js mais popular que gerencia o servidor web do jogo.
  • soquete.io - uma biblioteca websocket para troca de dados entre um navegador e um servidor.
  • Webpack - gerenciador de módulo. Você pode ler sobre por que usar o Webpack. aqui.

Esta é a aparência da estrutura de diretórios do projeto:

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

public /

Tudo em uma pasta public/ será enviado estaticamente pelo servidor. EM public/assets/ contém imagens usadas pelo nosso projeto.

src /

Todo o código fonte está na pasta src/. Natal client/ и server/ falam por si e shared/ contém um arquivo de constantes que é importado pelo cliente e pelo servidor.

2. Montagens/configurações do projeto

Conforme mencionado acima, usamos o gerenciador de módulo para construir o projeto. Webpack. Vamos dar uma olhada em 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 linhas mais importantes aqui são:

  • src/client/index.js é o ponto de entrada do cliente Javascript (JS). O Webpack começará a partir daqui e pesquisará recursivamente outros arquivos importados.
  • O JS de saída do nosso build Webpack estará localizado no diretório dist/. Vou chamar esse 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.
  • Estamos usando um plugin para extrair todos os CSS referenciados pelos arquivos JS e combiná-los em um só lugar. vou chamá-lo de nosso pacote css.

Você deve ter notado nomes de arquivos de pacotes estranhos '[name].[contenthash].ext'. Eles contém substituições de nome de arquivo webpack: [name] será substituído pelo nome do ponto de entrada (no nosso caso, este game) e [contenthash] será substituído por um hash do conteúdo do arquivo. Nós fazemos isso para otimizar o projeto para hash - você pode dizer aos navegadores para armazenarem nossos pacotes JS em cache indefinidamente, porque se um pacote for alterado, o nome do arquivo também será alterado (mudanças contenthash). O resultado final será o nome do arquivo de visualização game.dbeee76e91a97d0c7207.js.

arquivo webpack.common.js é o arquivo de configuração base que importamos para as configurações de desenvolvimento e finalização do projeto. Aqui está um exemplo de 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 no processo de desenvolvimento webpack.dev.js, e muda para webpack.prod.jspara otimizar tamanhos de pacotes ao implantar em produção.

Configuração local

Recomendo instalar o projeto em uma máquina local para que você possa seguir os passos listados neste post. A configuração é simples: primeiro o sistema deve ter instalado Node и NPM. Em seguida você precisa fazer

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

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

$ npm run develop

e vá para o navegador da web localhost: 3000. O servidor de desenvolvimento reconstruirá automaticamente os pacotes JS e CSS conforme o código for alterado - basta atualizar a página para ver todas as alterações!

3. Pontos de entrada do cliente

Vamos ao código do jogo em si. Primeiro precisamos de uma página index.html, ao visitar o site, o navegador irá carregá-lo primeiro. Nossa página será bem simples:

index.html

Um exemplo de jogo .io  JOGAR

Este exemplo de código foi ligeiramente simplificado para maior clareza e farei o mesmo com muitos dos outros exemplos de postagem. O código completo sempre pode ser visualizado em Github.

Nós temos:

  • Elemento de tela HTML5 (<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 o botão PLAY (<button>).

Após carregar a página inicial, o navegador começará a executar o código Javascript, a partir do arquivo JS do ponto 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 não há muita coisa acontecendo aqui:

  1. Importando vários outros arquivos JS.
  2. Importação de CSS (para que o Webpack saiba que deve incluí-los em nosso pacote CSS).
  3. Lançar connect() para estabelecer uma conexão com o servidor e executar downloadAssets() para baixar as imagens necessárias para renderizar o jogo.
  4. Após a conclusão da etapa 3 O menu principal está visível (playMenu).
  5. Configurando o manipulador para pressionar o botão "PLAY". Quando o botão é pressionado, o código inicializa o jogo e informa ao servidor que estamos prontos para jogar.

A principal "carne" da nossa lógica cliente-servidor está nos arquivos que foram importados pelo arquivo index.js. Agora vamos considerá-los todos em ordem.

4. Troca de dados de clientes

Neste jogo, usamos uma biblioteca conhecida para nos comunicarmos com o servidor soquete.io. Socket.io tem suporte nativo WebSockets, que são adequados para comunicação bidirecional: podemos enviar mensagens para o servidor и o servidor pode nos enviar mensagens na mesma conexão.

Teremos um arquivo src/client/networking.jsquem vai cuidar tudo comunicação com o servidor:

rede.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('Connected to server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Register 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 encurtado para maior clareza.

Existem três ações principais neste arquivo:

  • Estamos tentando nos conectar ao servidor. connectedPromise permitido apenas quando estabelecemos uma conexão.
  • Se a conexão for bem-sucedida, registramos funções de retorno de chamada (processGameUpdate() и onGameOver()) para mensagens que podemos receber do servidor.
  • Exportamos play() и updateDirection()para que outros arquivos possam usá-los.

5. Renderização do cliente

É hora de exibir a imagem na tela!

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

ativos.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(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

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

O gerenciamento de recursos não é tão difícil de implementar! A ideia principal é armazenar um objeto assets, que vinculará a chave do nome do arquivo ao valor do objeto Image. Quando o recurso é carregado, nós o armazenamos em um objeto assets para acesso rápido no futuro. Quando será permitido fazer download de cada recurso individual (ou seja, todos recursos), permitimos downloadPromise.

Depois de baixar os recursos, você pode começar a renderizar. Como dito anteriormente, para desenhar em uma página web, usamos Tela HTML5 (<canvas>). Nosso jogo é bem simples, então só precisamos desenhar o seguinte:

  1. fundo
  2. Navio do jogador
  3. Outros jogadores no jogo
  4. Conchas

Aqui estão os trechos importantes src/client/render.js, que renderiza 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;

// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Draw background
  renderBackground(me.x, me.y);

  // Draw all bullets
  bullets.forEach(renderBullet.bind(null, me));

  // Draw all players
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Helper functions here excluded

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

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

render() é a principal função deste arquivo. startRendering() и stopRendering() controlar a ativação do loop de renderização a 60 FPS.

Implementações concretas de funções auxiliares de renderização individuais (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,
  );
}

Observe que estamos usando o método getAsset(), o que foi visto anteriormente em asset.js!

Se você estiver interessado em aprender sobre outros auxiliares de renderização, leia o resto. src/client/render.js.

6. Entrada do cliente

É hora de fazer um 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, vamos registrar Ouvintes de eventos para eventos de mouse e toque.
Cuidará de tudo isso src/client/input.js:

entrada.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() и onTouchInput() são ouvintes de eventos que chamam updateDirection() (do networking.js) quando ocorre um evento de entrada (por exemplo, quando o mouse é movido). updateDirection() lida com mensagens com o servidor, que lida com o evento de entrada e atualiza o estado do jogo de acordo.

7. Status do cliente

Esta seção é a mais difícil da primeira parte do post. Não desanime se você não entender na primeira vez que ler! Você pode até pular e voltar mais tarde.

A última peça do quebra-cabeça necessária para completar o código cliente/servidor é estado. Lembra do trecho de código da seção Renderização do cliente?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

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 de 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: carimbo de data/hora do servidor que indica quando esta atualização foi criada.
  • me: Informações sobre o jogador que está recebendo esta atualização.
  • outras: Uma série de informações sobre outros jogadores que participam do mesmo jogo.
  • balas: uma série de informações sobre projéteis no jogo.
  • leaderboard: Dados atuais da tabela de classificação. Neste post, não os consideraremos.

7.1 Estado ingênuo do cliente

Implementação ingênua getCurrentState() só pode retornar diretamente os dados da atualização de jogo recebida mais recentemente.

estado ingênuo.js

let lastGameUpdate = null;

// Handle a newly received game update.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

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

Taxa de quadros: número de frames (ou seja, chamadas render()) por segundo ou FPS. Os jogos geralmente se esforçam para atingir pelo menos 60 FPS.

Taxa de tick: a frequência com que o servidor envia atualizações do jogo aos clientes. Muitas vezes é menor que a taxa de quadros. No nosso jogo, o servidor funciona a uma frequência de 30 ciclos por segundo.

Se apenas renderizarmos a atualização mais recente do jogo, o FPS essencialmente nunca ultrapassará 30, porque nunca recebemos mais de 30 atualizações por segundo do servidor. Mesmo se ligarmos render() 60 vezes por segundo, então metade dessas chamadas redesenharão a mesma coisa, essencialmente sem fazer nada. Outro problema com a implementação ingênua é que ela sujeito a atrasos. Com uma velocidade ideal de Internet, o cliente receberá uma atualização do jogo exatamente a cada 33ms (30 por segundo):

Criando um jogo web .io multijogador
Infelizmente, nada é perfeito. Uma imagem mais realista seria:
Criando um jogo web .io multijogador
A implementação ingênua é praticamente o pior caso quando se trata de latência. Se uma atualização do jogo for recebida com um atraso de 50 ms, então barracas de clientes 50 ms extras porque ainda está renderizando o estado do jogo da atualização anterior. Você pode imaginar como isso é desconfortável para o jogador: a frenagem arbitrária fará com que o jogo pareça irregular e instável.

7.2 Melhor estado do cliente

Faremos algumas melhorias na implementação ingênua. Primeiro, usamos atraso de renderização por 100ms. Isso significa que o estado "atual" do cliente sempre estará 100ms atrás do estado do jogo no servidor. Por exemplo, se a hora no servidor for 150, então o cliente renderizará o estado em que o servidor estava no momento 50:

Criando um jogo web .io multijogador
Isso nos dá um buffer de 100 ms para sobreviver a tempos imprevisíveis de atualização do jogo:

Criando um jogo web .io multijogador
A recompensa por isso será permanente atraso de entrada por 100ms. Este é um pequeno sacrifício para uma jogabilidade suave - a maioria dos jogadores (especialmente os jogadores casuais) nem notará esse atraso. É muito mais fácil para as pessoas se ajustarem a uma latência constante de 100 ms do que jogar com uma latência imprevisível.

Também podemos usar outra técnica chamada previsão do lado do cliente, que faz um bom trabalho na redução da latência percebida, mas não será abordado nesta postagem.

Outra melhoria que estamos usando é interpolação linear. Devido ao atraso de renderização, geralmente estamos pelo menos uma atualização à frente do horário atual no cliente. Quando chamado getCurrentState(), podemos executar interpolação linear entre as atualizações do jogo antes e depois do horário atual no cliente:

Criando um jogo web .io multijogador
Isso resolve o problema da taxa de quadros: agora podemos renderizar quadros únicos em qualquer taxa de quadros que desejarmos!

7.3 Implementando estado de cliente aprimorado

Exemplo de implementação em src/client/state.js usa atraso de renderização e interpolação linear, mas não por muito tempo. Vamos dividir o código em duas partes. Aqui está o primeiro:

estado.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);

  // Keep only one game update before the current server time
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

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

// Returns the index of the base update, the first game update before
// current server time, or -1 if 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;
}

O primeiro passo é descobrir o que currentServerTime(). Como vimos anteriormente, cada atualização do jogo inclui um carimbo de data/hora do servidor. Queremos usar a latência de renderização para renderizar a imagem 100 ms atrás do servidor, mas nunca saberemos a hora atual no servidor, porque não podemos saber quanto tempo levou para que alguma das atualizações chegasse até nós. A Internet é imprevisível e a sua velocidade pode variar muito!

Para contornar este problema, podemos usar uma aproximação razoável: fingir que a primeira atualização chegou instantaneamente. Se isso fosse verdade, saberíamos a hora do servidor neste momento específico! Armazenamos o carimbo de data/hora do servidor em firstServerTimestamp e manter o nosso local carimbo de data/hora (cliente) no mesmo momento em gameStart.

Oh espere. Não deveria ser hora do servidor = hora do cliente? Por que distinguimos entre “timestamp do servidor” e “timestamp do cliente”? Esta é uma grande pergunta! Acontece que eles não são a mesma coisa. Date.now() retornará carimbos de data/hora diferentes no cliente e no servidor e depende de fatores locais para essas máquinas. Nunca presuma que os carimbos de data/hora serão iguais em todas as máquinas.

Agora entendemos o que significa currentServerTime(): retorna o carimbo de data/hora do servidor do tempo de renderização atual. Em outras palavras, esta é a hora atual do servidor (firstServerTimestamp <+ (Date.now() - gameStart)) menos atraso de renderização (RENDER_DELAY).

Agora vamos dar uma olhada em como lidamos com as atualizações do jogo. Quando recebido do servidor de atualização, é chamado 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 antes atualização básicaporque não precisamos mais deles.

O que é uma “atualização básica”? Esse a primeira atualização que encontramos retrocedendo a partir da hora atual do servidor. Lembra deste diagrama?

Criando um jogo web .io multijogador
A atualização do jogo diretamente à esquerda de “Client Render Time” é a atualização básica.

Para que é usada a atualização básica? Por que podemos descartar as atualizações para a linha de base? Para descobrir isso, vamos finalmente considere a implementação getCurrentState():

estado.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),
    };
  }
}

Lidamos com três casos:

  1. base < 0 significa que não há atualizações até o tempo de renderização atual (veja a implementação acima getBaseUpdate()). Isso pode acontecer logo no início do jogo devido ao atraso de renderização. Neste caso, utilizamos a última atualização recebida.
  2. base é a atualização mais recente que temos. Isso pode ser devido a atraso na rede ou conexão deficiente com a Internet. Neste caso, também estamos usando a atualização mais recente que temos.
  3. Temos uma atualização antes e depois do tempo de renderização atual, para que possamos interpolar!

Tudo o que resta state.js é uma implementação de interpolação linear que é matemática simples (mas enfadonha). Se você quiser explorá-lo sozinho, abra state.js em Github.

Parte 2. Servidor back-end

Nesta parte, daremos uma olhada no backend do Node.js que controla nosso exemplo de jogo .io.

1. Ponto de entrada do servidor

Para gerenciar o servidor web, usaremos uma estrutura web popular para Node.js chamada Express. Ele será configurado pelo arquivo de ponto de entrada do nosso 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');

// Setup an Express server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Setup Webpack for development
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Static serve the dist/ folder in production
  app.use(express.static('dist'));
}

// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

Lembra que na primeira parte discutimos o Webpack? É aqui que usaremos nossas configurações do Webpack. Iremos usá-los de duas maneiras:

  • Usar webpack-dev-middleware para reconstruir automaticamente nossos pacotes de desenvolvimento, ou
  • pasta de transferência estaticamente dist/, no qual o Webpack gravará nossos arquivos após a compilação de produção.

Outra tarefa importante server.js é configurar o servidor soquete.ioque apenas se conecta ao servidor Express:

server.js parte 2

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

// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

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

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', socket.id);

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

Depois de estabelecer com sucesso uma conexão socket.io com o servidor, configuramos manipuladores de eventos para o novo soquete. Manipuladores de eventos tratam mensagens recebidas de clientes delegando a um objeto singleton game:

server.js parte 3

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

// ...

// Setup the Game
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 .io, então só precisamos de uma cópia Game ("Jogo") - todos os jogadores jogam na mesma arena! Na próxima seção, veremos como essa classe funciona. Game.

2. Servidores de jogos

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

Vamos começar com a 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;

    // Generate a position to start this player at.
    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 identificaremos os jogadores pelo campo id seu soquete socket.io (se você ficar confuso, volte para server.js). O próprio Socket.io atribui a cada soquete um único identão não precisamos nos preocupar com isso. vou ligar para ele ID do jogador.

Com isso em mente, vamos explorar variáveis ​​de instância em uma classe Game:

  • sockets é um objeto que vincula o ID do jogador ao soquete associado ao jogador. Permite-nos aceder a sockets através dos seus IDs de jogador num tempo constante.
  • players é um objeto que vincula o ID do jogador ao código> objeto Player

bullets é uma matriz de objetos Bullet, que não tem ordem definida.
lastUpdateTime é o carimbo de data/hora da última vez que o jogo foi atualizado. Veremos como ele será usado em breve.
shouldSendUpdate é uma variável auxiliar. Também veremos seu uso em breve.
Métodos addPlayer(), removePlayer() и handleInput() não há necessidade de explicar, eles são usados ​​em server.js. Se precisar refrescar a memória, volte um pouco mais alto.

Última linha constructor() começa ciclo de atualização jogos (com frequência de 60 atualizações/s):

game.js parte 2

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

class Game {
  // ...

  update() {
    // Calculate time elapsed
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update each bullet
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

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

    // Apply collisions, give players score for hitting bullets
    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),
    );

    // Check if any players are dead
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp <= 0) {
        socket.emit(Constants.MSG_TYPES.GAME_OVER);
        this.removePlayer(socket);
      }
    });

    // Send a game update to each player every other time
    if (this.shouldSendUpdate) {
      const leaderboard = this.getLeaderboard();
      Object.keys(this.sockets).forEach(playerID => {
        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 update() contém talvez a parte mais importante da lógica do lado do servidor. Aqui está o que ele faz, em ordem:

  1. Calcula quanto tempo dt passou desde o último update().
  2. Atualiza cada projétil e os destrói se necessário. Veremos a implementação desta funcionalidade posteriormente. Por enquanto, basta-nos saber que bullet.update() devolve truese o projétil deve ser destruído (ele saiu da arena).
  3. Atualiza cada jogador e gera um projétil, se necessário. Também veremos esta implementação mais tarde - player.update() pode retornar um objeto Bullet.
  4. Verifica colisões entre projéteis e jogadores com applyCollisions(), que retorna uma série de projéteis que atingem os jogadores. Para cada projétil devolvido, aumentamos os pontos do jogador que o disparou (usando player.onDealtDamage()) e, em seguida, remova o projétil da matriz bullets.
  5. Notifica e destrói todos os jogadores mortos.
  6. Envia uma atualização do jogo para todos os jogadores todo segundo vezes quando chamado update(). Isso nos ajuda a acompanhar a variável auxiliar mencionada acima. shouldSendUpdate. Como update() chamados 60 vezes/s, enviamos atualizações do jogo 30 vezes/s. Por isso, frequência do relógio o clock do servidor é de 30 clocks/s (falamos sobre taxas de clock na primeira parte).

Por que enviar apenas atualizações de jogos através do tempo ? Para salvar o canal. 30 atualizações de jogo por segundo é muito!

Por que não ligar update() 30 vezes por segundo? Para melhorar a simulação do jogo. Quanto mais frequentemente chamado update(), mais precisa será a simulação do jogo. Mas não se deixe levar pela quantidade de desafios. update(), porque esta é uma tarefa computacionalmente cara - 60 por segundo é suficiente.

O resto da aula Game consiste em métodos auxiliares usados ​​em update():

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) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() muito simples - classifica os jogadores por pontuação, pega os cinco primeiros e retorna o nome de usuário e a pontuação de cada um.

createUpdate() usado em update() para criar atualizações de jogos que são distribuídas aos jogadores. Sua principal tarefa é chamar métodos serializeForUpdate()implementado para aulas Player и Bullet. Observe que ele apenas passa dados para cada jogador sobre mais próximo jogadores 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, redondos e móveis. Para aproveitar essa semelhança entre jogadores e projéteis, vamos começar implementando a classe base Object:

objeto.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,
    };
  }
}

Não há nada complicado acontecendo aqui. Esta aula será um bom ponto de ancoragem para a extensão. Vamos ver como a aula Bullet usa Object:

bala.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;
  }

  // Returns true if the bullet should be destroyed
  update(dt) {
    super.update(dt);
    return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

Implementação Bullet muito curto! Nós adicionamos a Object apenas as seguintes extensões:

  • Usando um pacote shortid para geração aleatória id concha.
  • Adicionando um campo parentIDpara que você possa rastrear o jogador que criou este projétil.
  • Adicionando um valor de retorno a update()que é igual truese o projétil estiver fora da arena (lembra que falamos sobre isso na última seção?).

Vamos passar para Player:

jogador.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;
  }

  // Returns a newly created bullet, or null.
  update(dt) {
    super.update(dt);

    // Update score
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Make sure the player stays in bounds
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Fire a bullet, if needed
    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 que os projéteis, então mais alguns campos devem ser armazenados nesta classe. Seu método update() faz muito trabalho, em particular, retorna o projétil recém-criado se não sobrar nenhum fireCooldown (lembra que falamos sobre isso na seção anterior?). Também estende o método serializeForUpdate(), porque precisamos incluir campos adicionais para o jogador na atualização do jogo.

Ter uma classe base Object - um passo importante para evitar a repetição de código. Por exemplo, nenhuma aula Object cada objeto do jogo deve ter a mesma implementação distanceTo(), e copiar e colar todas essas implementações em vários arquivos seria um pesadelo. Isto se torna especialmente importante para grandes projetos.quando o número de expansão Object as aulas estão crescendo.

4. Detecção de colisão

A única coisa que nos resta é reconhecer quando os projéteis atingem os jogadores! Lembre-se deste trecho de código do método update() em aula Game:

jogo.js

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

class Game {
  // ...

  update() {
    // ...

    // Apply collisions, give players score for hitting bullets
    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, não é tão difícil de fazer porque

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

Esta é a aparência de nossa implementação de detecção de colisão:

colisões.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 simples detecção de colisão é baseada no fato de que dois círculos colidem se a distância entre seus centros for menor que a soma de seus raios. Este é o caso em que a distância entre os centros de dois círculos é exatamente igual à soma dos seus raios:

Criando um jogo web .io multijogador
Há mais alguns aspectos a serem considerados aqui:

  • O projétil não deve atingir o jogador que o criou. Isto pode ser conseguido comparando bullet.parentID с player.id.
  • O projétil deve atingir apenas uma vez no caso limite de vários jogadores colidirem ao mesmo tempo. Resolveremos este problema usando o operador break: assim que for encontrado o jogador que colidiu com o projétil, paramos a busca e passamos para o próximo projétil.

Конец

Isso é tudo! Cobrimos tudo que você precisa saber para criar um jogo web .io. Qual é o próximo? Crie seu próprio jogo .io!

Todo o código de amostra é de código aberto e publicado em Github.

Fonte: habr.com

Adicionar um comentário