Creando un xogo web multixogador .io

Creando un xogo web multixogador .io
Lanzado en 2015 Agar.io converteuse no proxenitor dun novo xénero xogos .io, cuxa popularidade creceu moito desde entón. Eu mesmo experimentei o aumento da popularidade dos xogos .io: nos últimos tres anos, eu creou e vendeu dous xogos deste xénero..

No caso de que nunca escoitaches falar destes xogos antes, son xogos web gratuítos e multixogador que son fáciles de xogar (non se precisa ningunha conta). Adoitan enfrontarse a moitos xogadores contrarios no mesmo campo. Outros xogos .io famosos: Slither.io и Diep.io.

Neste post, exploraremos como crea un xogo .io desde cero. Para iso, só será suficiente o coñecemento de Javascript: cómpre comprender cousas como a sintaxe ES6, palabra clave this и Promesas. Aínda que o teu coñecemento de Javascript non sexa perfecto, aínda podes comprender a maior parte da publicación.

Exemplo de xogo .io

Para asistencia formativa referirémonos Exemplo de xogo .io. Tenta xogalo!

Creando un xogo web multixogador .io
O xogo é moi sinxelo: controlas un barco nunha area onde hai outros xogadores. O teu barco dispara automaticamente proxectís e intentas golpear a outros xogadores mentres evitas os seus proxectís.

1. Breve descrición xeral/estrutura do proxecto

recomendar descargar código fonte xogo de exemplo para que poidas seguirme.

O exemplo usa o seguinte:

  • Expresado é o marco web máis popular para Node.js que xestiona o servidor web do xogo.
  • socket.io - unha biblioteca websocket para intercambiar datos entre un navegador e un servidor.
  • Webpack - Xestor de módulos. Podes ler sobre por que usar Webpack. aquí.

Aquí está a estrutura do directorio do proxecto:

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

público/

Todo nun cartafol public/ será enviado de forma estática polo servidor. EN public/assets/ contén imaxes utilizadas polo noso proxecto.

src /

Todo o código fonte está no cartafol src/. Títulos client/ и server/ falan por si mesmos e shared/ contén un ficheiro de constantes que é importado tanto polo cliente como polo servidor.

2. Montaxes/Configuración do proxecto

Como se indicou anteriormente, usamos un xestor de módulos para construír o proxecto Webpack. Vexamos a configuración do noso 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 liñas máis importantes aquí son:

  • src/client/index.js é o punto de entrada do cliente Javascript (JS). Webpack comezará desde aquí e buscará recursivamente outros ficheiros importados.
  • O JS de saída da nosa compilación Webpack estará situado no directorio dist/. Chamarei a este ficheiro noso paquete js.
  • Usamos Babel, e en particular a configuración @babel/preset-env para transpilar o noso código JS para navegadores máis antigos.
  • Estamos a usar un complemento para extraer todos os CSS referenciados polos ficheiros JS e combinalos nun só lugar. Chamareino noso paquete css.

Quizais teña notado nomes estraños de ficheiros de paquetes '[name].[contenthash].ext'. Conteñen substitucións de nomes de ficheiros Paquete web: [name] substituirase polo nome do punto de entrada (no noso caso é game), e [contenthash] substituirase por un hash do contido do ficheiro. Facémolo para optimizar o proxecto para o hash - Podes dicir aos navegadores que almacene na caché os nosos paquetes JS indefinidamente, porque se cambia un paquete, o seu nome de ficheiro tamén cambia (cambios contenthash). O resultado final será o nome do ficheiro de visualización game.dbeee76e91a97d0c7207.js.

arquivo webpack.common.js é o ficheiro de configuración base que importamos ás configuracións do proxecto de desenvolvemento e rematado. Aquí tes un exemplo de configuración de desenvolvemento:

webpack.dev.js

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

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

Para a eficiencia, usamos no proceso de desenvolvemento webpack.dev.js, e cambia a webpack.prod.jspara optimizar o tamaño dos paquetes ao implementar a produción.

Configuración local

Recomendo instalar o proxecto nunha máquina local para que poida seguir os pasos que se indican nesta publicación. A configuración é sinxela: primeiro, o sistema debe ter instalado Nodo и NPM. A continuación tes que facer

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

e estás listo para ir! Para iniciar o servidor de desenvolvemento, só tes que executar

$ npm run develop

e vai ao navegador web localhost: 3000. O servidor de desenvolvemento reconstruirá automaticamente os paquetes JS e CSS a medida que se produzan cambios no código; só tes que actualizar a páxina para ver todos os cambios.

3. Puntos de entrada de clientes

Pasemos ao propio código do xogo. Primeiro necesitamos unha páxina index.html, cando visite o sitio, o navegador cargarao primeiro. A nosa páxina será ben sinxela:

index.html

Un exemplo de xogo .io  XOGAR

Este exemplo de código simplificouse lixeiramente para claridade, e farei o mesmo con moitos dos outros exemplos de publicacións. O código completo sempre se pode ver en Github.

Temos:

  • Elemento de lenzo HTML5 (<canvas>) que usaremos para renderizar o xogo.
  • <link> para engadir o noso paquete CSS.
  • <script> para engadir o noso paquete Javascript.
  • Menú principal con nome de usuario <input> e o botón PLAY (<button>).

Despois de cargar a páxina de inicio, o navegador comezará a executar código Javascript, a partir do ficheiro JS do punto de entrada: src/client/index.js.

índice.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);
  };
});

Isto pode parecer complicado, pero aquí non hai moito:

  1. Importando outros ficheiros JS.
  2. Importar CSS (polo que Webpack sabe incluílos no noso paquete CSS).
  3. Lanzamento connect() para establecer unha conexión co servidor e executalo downloadAssets() para descargar as imaxes necesarias para renderizar o xogo.
  4. Despois de completar a fase 3 móstrase o menú principal (playMenu).
  5. Configurando o controlador para premer o botón "PLAY". Cando se preme o botón, o código inicializa o xogo e indica ao servidor que estamos preparados para xogar.

A "carne" principal da nosa lóxica cliente-servidor está naqueles ficheiros que foron importados polo ficheiro index.js. Agora considerarémolos todos en orde.

4. Intercambio de datos de clientes

Neste xogo, usamos unha biblioteca coñecida para comunicarnos co servidor socket.io. Socket.io ten soporte nativo sockets web, que son moi axeitados para a comunicación bidireccional: podemos enviar mensaxes ao servidor и o servidor pode enviarnos mensaxes na mesma conexión.

Teremos un arquivo src/client/networking.jsquen se ocupará todos comunicación co servidor:

redes.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 tamén se acurta lixeiramente para obter claridade.

Neste ficheiro hai tres accións principais:

  • Estamos tentando conectarnos ao servidor. connectedPromise só se permite cando establecemos unha conexión.
  • Se a conexión é exitosa, rexistramos funcións de devolución de chamada (processGameUpdate() и onGameOver()) para as mensaxes que podemos recibir do servidor.
  • Exportamos play() и updateDirection()para que outros ficheiros poidan utilizalos.

5. Representación do cliente

É hora de mostrar a imaxe na pantalla!

…pero antes de que poidamos facelo, necesitamos descargar todas as imaxes (recursos) que son necesarios para iso. Escribamos un xestor de recursos:

activos.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];

A xestión de recursos non é tan difícil de implementar! A idea principal é almacenar un obxecto assets, que unirá a clave do nome do ficheiro co valor do obxecto Image. Cando se carga o recurso, gardámolo nun obxecto assets para un acceso rápido no futuro. Cando se permitirá descargar cada recurso individual (é dicir, todo recursos), permitimos downloadPromise.

Despois de descargar os recursos, pode comezar a renderizar. Como dixemos anteriormente, para debuxar nunha páxina web, utilizamos lenzo HTML5 (<canvas>). O noso xogo é bastante sinxelo, polo que só necesitamos debuxar o seguinte:

  1. Antecedentes
  2. Barco de xogadores
  3. Outros xogadores do xogo
  4. Cunchas

Aquí están os fragmentos importantes src/client/render.js, que representan exactamente os catro elementos enumerados anteriormente:

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 tamén se acurta para claridade.

render() é a función principal deste ficheiro. startRendering() и stopRendering() controlar a activación do bucle de renderizado a 60 FPS.

Implementacións concretas de funcións auxiliares de renderización individuais (p. ex. renderBullet()) non son tan importantes, pero aquí tes un exemplo sinxelo:

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

Teña en conta que estamos a usar o método getAsset(), que se vía anteriormente en asset.js!

Se estás interesado en explorar outras funcións auxiliares de renderizado, le o resto de src/client/render.js.

6. Entrada do cliente

É hora de facer un xogo xogable! O esquema de control será moi sinxelo: para cambiar a dirección do movemento, podes usar o rato (nun ordenador) ou tocar a pantalla (nun dispositivo móbil). Para implementar isto, rexistrarémonos Oíntes de eventos para eventos Mouse and Touch.
Encargarase de todo isto 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() son oíntes de eventos que chaman updateDirection() (desde networking.js) cando se produce un evento de entrada (por exemplo, cando se move o rato). updateDirection() encárgase do intercambio de mensaxes co servidor, que procesa o evento de entrada e actualiza o estado do xogo en consecuencia.

7. Estado do cliente

Esta sección é a máis difícil na primeira parte do post. Non te desanimes se non o entendes a primeira vez que o lees! Incluso podes omitilo e volver a el máis tarde.

A última peza do puzzle necesaria para completar o código cliente/servidor é foron. Lembras o fragmento de código da sección Representación do cliente?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() debería poder darnos o estado actual do xogo no cliente en calquera momento baseado nas actualizacións recibidas do servidor. Aquí tes un exemplo de actualización do xogo que pode enviar o servidor:

{
  "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 actualización do xogo contén cinco campos idénticos:

  • t: marca de tempo do servidor que indica cando se creou esta actualización.
  • me: información sobre o xogador que recibe esta actualización.
  • outros: unha serie de información sobre outros xogadores que participan no mesmo xogo.
  • balas: unha serie de información sobre proxectís no xogo.
  • clasificación: datos actuais da táboa de clasificación. Non os teremos en conta neste post.

7.1 Estado inxenuo do cliente

Implementación inxenua getCurrentState() só pode devolver directamente os datos da actualización do xogo máis recente recibida.

inxenuo-estado.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Bonito e claro! Pero se fose tan sinxelo. Unha das razóns polas que esta implementación é problemática é: limita a velocidade de fotogramas de renderizado á velocidade do reloxo do servidor.

Velocidade de cadros: número de fotogramas (é dicir, chamadas render()) por segundo o FPS. Os xogos adoitan esforzarse en acadar polo menos 60 FPS.

Taxa de tick: A frecuencia coa que o servidor envía actualizacións de xogos aos clientes. Moitas veces é inferior á taxa de fotogramas. No noso xogo, o servidor funciona cunha frecuencia de 30 ciclos por segundo.

Se só renderizamos a última actualización do xogo, entón o FPS nunca pasará de 30, porque nunca recibimos máis de 30 actualizacións por segundo do servidor. Aínda que chamemos render() 60 veces por segundo, a metade destas chamadas simplemente volverá a debuxar o mesmo, sen facer nada. Outro problema cunha implementación inxenua é que é propenso a atrasos. Cunha velocidade de Internet ideal, o cliente recibirá unha actualización do xogo exactamente cada 33 ms (30 por segundo):

Creando un xogo web multixogador .io
Por desgraza, nada é perfecto. Unha imaxe máis realista sería:
Creando un xogo web multixogador .io
A implementación inxenua é practicamente o peor dos casos cando se trata de latencia. Se recibe unha actualización do xogo cun atraso de 50 ms, entón o cliente é máis lento por 50 ms adicionais porque aínda está mostrando o estado do xogo desde a actualización anterior. Podes imaxinar o incómodo que é para o xogador: a freada arbitraria fará que o xogo se sinta brusco e inestable.

7.2 Estado do cliente mellorado

Faremos algunhas melloras na implementación inxenua. En primeiro lugar, usamos atraso de renderización durante 100 ms. Isto significa que o estado "actual" do cliente sempre quedará atrás do estado do xogo no servidor en 100 ms. Por exemplo, se a hora no servidor é 150, entón o cliente mostrará o estado no que estaba o servidor nese momento 50:

Creando un xogo web multixogador .io
Isto ofrécenos un búfer de 100 ms para sobrevivir ao tempo imprevisible das actualizacións do xogo:

Creando un xogo web multixogador .io
A recompensa por isto será permanente atraso de entrada durante 100 ms. Este é un sacrificio menor para un xogo fluido: a maioría dos xogadores (especialmente os xogadores casuales) nin sequera notarán este atraso. É moito máis fácil para a xente axustarse a unha latencia constante de 100 ms que xogar cunha latencia imprevisible.

Tamén podemos utilizar outra técnica chamada "previsión do cliente", que fai un bo traballo para reducir a latencia percibida, pero non se comentará nesta publicación.

Outra mellora que estamos a utilizar é interpolación lineal. Debido ao atraso de renderización, adoitamos estar polo menos unha actualización por diante da hora actual no cliente. Cando se chama getCurrentState(), podemos executar interpolación lineal entre as actualizacións do xogo xusto antes e despois da hora actual do cliente:

Creando un xogo web multixogador .io
Isto resolve o problema da velocidade de fotogramas: agora podemos renderizar fotogramas únicos a calquera velocidade de fotogramas que necesitemos.

7.3 Implementación do estado do cliente mellorado

Exemplo de implementación en src/client/state.js usa tanto o atraso de renderizado como a interpolación lineal, pero isto non dura moito. Imos dividir o código en dúas partes. Aquí está o primeiro:

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

  // 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 paso é descubrir o que currentServerTime(). Como vimos anteriormente, cada actualización do xogo inclúe unha marca de tempo do servidor. Queremos usar a latencia de renderizado para renderizar a imaxe 100 ms detrás do servidor, pero nunca saberemos a hora actual no servidor, porque non podemos saber canto tempo tardou en chegar algunha das actualizacións. Internet é imprevisible e a súa velocidade pode variar moito.

Para sortear este problema, podemos utilizar unha aproximación razoable: nós imos finxir que a primeira actualización chegou ao instante. Se isto fose certo, saberiamos a hora do servidor neste momento concreto. Almacenamos a marca de tempo do servidor firstServerTimestamp e manter o noso local marca de tempo (cliente) no mesmo momento gameStart.

Ai espera. Non debería ser hora do servidor = hora do cliente? Por que distinguimos entre "marca de tempo do servidor" e "marca de tempo do cliente"? Esta é unha gran pregunta! Resulta que non son o mesmo. Date.now() devolverá diferentes marcas de tempo no cliente e no servidor e isto depende dos factores locais destas máquinas. Nunca asuma que as marcas de tempo serán as mesmas en todas as máquinas.

Agora entendemos o que fai currentServerTime(): volve a marca de tempo do servidor do tempo de renderizado actual. Noutras palabras, esta é a hora actual do servidor (firstServerTimestamp <+ (Date.now() - gameStart)) menos o atraso de renderización (RENDER_DELAY).

Agora vexamos como xestionamos as actualizacións dos xogos. Cando se recibe do servidor de actualizacións, chámase processGameUpdate()e gardamos a nova actualización nunha matriz gameUpdates. Despois, para comprobar o uso da memoria, eliminamos todas as actualizacións antigas anteriores actualización baseporque xa non os necesitamos.

Que é unha "actualización básica"? Isto a primeira actualización que atopamos ao retroceder desde a hora actual do servidor. Lembras este diagrama?

Creando un xogo web multixogador .io
A actualización do xogo directamente á esquerda de "Client Render Time" é a actualización base.

Para que serve a actualización base? Por que podemos deixar as actualizacións á liña base? Para descubrir isto, imos finalmente considerar a implementación 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),
    };
  }
}

Tratamos tres casos:

  1. base < 0 significa que non hai actualizacións ata o momento de renderización actual (consulte a implementación anterior getBaseUpdate()). Isto pode ocorrer ao comezo do xogo debido ao atraso de renderización. Neste caso, utilizamos a última actualización recibida.
  2. base é a última actualización que temos. Isto pode deberse a un atraso na rede ou a unha mala conexión a Internet. Neste caso, tamén estamos a usar a última actualización que temos.
  3. Temos unha actualización antes e despois do tempo de renderizado actual, polo que podemos interpolar!

Todo o que queda dentro state.js é unha implementación de interpolación lineal que é matemática sinxela (pero aburrida). Se queres exploralo ti mesmo, ábrelo state.js en Github.

Parte 2. Servidor de backend

Nesta parte, botaremos unha ollada ao backend de Node.js que controla o noso exemplo dun xogo .io.

1. Punto de entrada do servidor

Para xestionar o servidor web usaremos un framework web popular para Node.js chamado Expresado. Será configurado polo noso ficheiro de punto 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');

// 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}`);

Lembras que na primeira parte falamos de Webpack? Aquí é onde usaremos as nosas configuracións Webpack. Aplicarémolos de dúas formas:

  • Usa webpack-dev-middleware para reconstruír automaticamente os nosos paquetes de desenvolvemento ou
  • Transferir estáticamente un cartafol dist/, no que Webpack escribirá os nosos ficheiros despois da compilación de produción.

Outra tarefa importante server.js é configurar o servidor socket.ioque simplemente 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);
});

Despois de establecer con éxito unha conexión socket.io co servidor, configuramos os controladores de eventos para o novo socket. Os controladores de eventos procesan as mensaxes recibidas dos clientes delegando nun obxecto 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 a crear un xogo .io, polo que só necesitamos unha copia Game ("Xogo"): todos os xogadores xogan no mesmo campo! Na seguinte sección veremos como funciona esta clase Game.

2. Servidores de xogos

Clase Game contén a lóxica do servidor máis importante. Ten dúas tarefas principais: xestión de xogadores и simulación de xogos.

Comecemos pola primeira tarefa, a xestión de xogadores.

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 xogo identificaremos xogadores por campo id o seu socket.io (se te confundes, volve a server.js). O propio Socket.io asigna a cada socket un único id, polo que non temos que preocuparnos por iso. Vou chamalo ID do xogador.

Con isto en mente, examinemos as variables de instancia na clase Game:

  • sockets é un obxecto que une o ID do xogador ao socket que está asociado co xogador. Permítenos acceder aos sockets mediante os seus ID de xogadores ao longo do tempo.
  • players é un obxecto que une o ID do xogador co código>Obxecto do xogador

bullets é unha matriz de obxectos Bullet, que non ten orde definida.
lastUpdateTime é a marca de tempo da última vez que se actualizou o xogo. En breve veremos como se usa.
shouldSendUpdate é unha variable auxiliar. Tamén veremos o seu uso en breve.
Métodos addPlayer(), removePlayer() и handleInput() non hai que explicar, utilízanse en server.js. Se necesitas un repaso, retrocede un pouco máis arriba.

Última liña constructor() arrinca ciclo de actualización xogos (cunha frecuencia de 60 actualizacións/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én probablemente a parte máis importante da lóxica do servidor. Imos enumerar todo o que fai por orde:

  1. Calcula que hora é dt foi dende o último update().
  2. Actualiza cada proxectil e destrúeos se é necesario. Veremos a implementación desta funcionalidade máis adiante. Polo de agora, abonda con que o saibamos bullet.update() volve truese o proxectil debe ser destruído (saíu fóra da area).
  3. Actualiza cada xogador e crea un proxectil se é necesario. Tamén veremos esta implementación máis tarde - player.update() pode devolver un obxecto Bullet.
  4. Comproba se hai colisións entre proxectís e xogadores que usan applyCollisions(), que devolve unha serie de proxectís que golpean aos xogadores. Por cada proxectil devolto, aumentamos os puntos do xogador que o disparou (usando player.onDealtDamage()) e despois retire o proxectil da matriz bullets.
  5. Notifica e destrúe todos os xogadores mortos.
  6. Envía unha actualización do xogo a todos os xogadores cada segundo veces cando se chama update(). Isto axúdanos a facer un seguimento da variable auxiliar mencionada anteriormente. shouldSendUpdate. Como update() chamado 60 veces/s, enviamos actualizacións do xogo 30 veces/s. Así, frecuencia de reloxo o reloxo do servidor é de 30 reloxos/s (falamos sobre as taxas de reloxo na primeira parte).

Por que enviar só actualizacións do xogo a través do tempo ? Para gardar a canle. 30 actualizacións de xogos por segundo son moitas!

Por que non chamar update() 30 veces por segundo? Para mellorar a simulación do xogo. Canto máis a miúdo se chame update(), máis precisa será a simulación do xogo. Pero non te deixes levar pola cantidade de retos. update(), porque esta é unha tarefa computacionalmente custosa - 60 por segundo é suficiente.

O resto da clase Game consiste en métodos auxiliares utilizados en 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() É bastante sinxelo: ordena os xogadores por puntuación, leva os cinco primeiros e devolve o nome de usuario e a puntuación de cada un.

createUpdate() usado en update() para crear actualizacións de xogos que se distribúen aos xogadores. A súa tarefa principal é chamar métodos serializeForUpdate()implementado para clases Player и Bullet. Teña en conta que só pasa datos a cada xogador sobre máis próximos xogadores e proxectís: non é necesario transmitir información sobre obxectos do xogo situados lonxe do xogador.

3. Obxectos de xogo no servidor

No noso xogo, os proxectís e os xogadores son en realidade moi semellantes: son obxectos de xogo abstractos, redondos e móbiles. Para aproveitar esta semellanza entre xogadores e proxectís, imos comezar por implementar a clase base Object:

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

Aquí non hai nada complicado. Esta clase será un bo punto de ancoraxe para a extensión. A ver como vai a clase Bullet usos 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;
  }

  // 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;
  }
}

Implantación Bullet moi curto! Engadimos a Object só as seguintes extensións:

  • Usando o paquete curtido para a xeración aleatoria id proxectil.
  • Engadindo un campo parentIDpara que poidas rastrexar o xogador que creou este proxectil.
  • Engadindo un valor de retorno a update(), que é igual a true, se o proxectil está fóra da area (lembras que falamos disto na última sección?).

Pasemos a Player:

xogador.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 xogadores son máis complexos que os proxectís, polo que esta clase debería almacenar algúns campos máis. O seu método update() fai moito traballo, en particular, devolve o proxectil recén creado se non queda ningún fireCooldown (lembras que falamos disto na sección anterior?). Tamén amplía o método serializeForUpdate(), porque necesitamos incluír campos adicionais para o xogador na actualización do xogo.

Ter unha clase base Object - un paso importante para evitar a repetición do código. Por exemplo, sen clase Object cada obxecto do xogo debe ter a mesma implementación distanceTo(), e copiar e pegar todas estas implementacións en varios ficheiros sería un pesadelo. Isto faise especialmente importante para grandes proxectos., cando o número de expansión Object as clases medran.

4. Detección de colisións

O único que nos queda por facer é recoñecer cando os proxectís golpean os xogadores! Lembra este fragmento de código do método update() en clase Game:

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

    // ...
  }
}

Necesitamos implementar o método applyCollisions(), que devolve todos os proxectís que golpean aos xogadores. Por sorte, non é tan difícil de facelo porque

  • Todos os obxectos que chocan son círculos, que é a forma máis sinxela para implementar a detección de colisións.
  • Xa temos un método distanceTo(), que implementamos na sección anterior na clase Object.

Este é o aspecto da nosa implementación da detección de colisións:

colisións.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 simple detección de colisións baséase no feito de que dous círculos chocan se a distancia entre os seus centros é menor que a suma dos seus raios. Aquí está o caso no que a distancia entre os centros de dous círculos é exactamente igual á suma dos seus raios:

Creando un xogo web multixogador .io
Hai un par de aspectos máis a considerar aquí:

  • O proxectil non debe golpear ao xogador que o creou. Isto pódese conseguir comparando bullet.parentID с player.id.
  • O proxectil só debe golpear unha vez no caso límite de que varios xogadores choquen ao mesmo tempo. Resolveremos este problema usando o operador break: en canto se atopa o xogador que choca co proxectil, paramos a busca e pasamos ao seguinte proxectil.

Fin

Iso é todo! Cubrimos todo o que necesitas saber para crear un xogo web .io. Que segue? Constrúe o teu propio xogo .io!

Todo o código de exemplo é de código aberto e está publicado Github.

Fonte: www.habr.com

Engadir un comentario