Création d'un jeu Web .io multijoueur

Création d'un jeu Web .io multijoueur
Sorti en 2015 Agar.io est devenu le géniteur d'un nouveau genre jeux .ioqui a depuis gagné en popularité. J'ai personnellement vécu la montée en popularité des jeux .io : au cours des trois dernières années, j'ai créé et vendu deux jeux de ce genre..

Si vous n'avez jamais entendu parler de ces jeux auparavant, ce sont des jeux Web multijoueurs gratuits et faciles à jouer (aucun compte requis). Ils affrontent généralement de nombreux joueurs adverses dans la même arène. Autres jeux .io célèbres : Slither.io и Diep.io.

Dans cet article, nous explorerons comment créer un jeu .io à partir de zéro. Pour cela, seule la connaissance de Javascript suffira : vous devez comprendre des choses comme la syntaxe. ES6, mot-clé this и Promesses. Même si votre connaissance de Javascript n'est pas parfaite, vous pouvez quand même comprendre la majeure partie du message.

Exemple de jeu .io

Pour l’aide à l’apprentissage, nous ferons référence à Exemple de jeu .io. Essayez d'y jouer !

Création d'un jeu Web .io multijoueur
Le jeu est assez simple : vous contrôlez un vaisseau dans une arène où se trouvent d'autres joueurs. Votre vaisseau tire automatiquement des projectiles et vous essayez de toucher les autres joueurs tout en évitant leurs projectiles.

1. Bref aperçu / structure du projet

recommander télécharger le code source exemple de jeu pour que vous puissiez me suivre.

L'exemple utilise les éléments suivants :

  • Express est le framework Web Node.js le plus populaire qui gère le serveur Web du jeu.
  • socket.io - une bibliothèque websocket pour échanger des données entre un navigateur et un serveur.
  • Webpack - gestionnaire de modules. Vous pouvez lire pourquoi utiliser Webpack. ici.

Voici à quoi ressemble la structure des répertoires du projet :

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

Publique/

Tout dans un dossier public/ sera soumis statiquement par le serveur. DANS public/assets/ contient des images utilisées par notre projet.

src /

Tout le code source est dans le dossier src/... Noms client/ и server/ parler pour eux-mêmes et shared/ contient un fichier de constantes importé à la fois par le client et le serveur.

2. Assemblages/Paramètres du projet

Comme mentionné ci-dessus, nous utilisons le gestionnaire de modules pour construire le projet. Webpack. Jetons un coup d'œil à notre configuration 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',
    }),
  ],
};

Les lignes les plus importantes ici sont :

  • src/client/index.js est le point d'entrée du client Javascript (JS). Webpack commencera à partir d'ici et recherchera récursivement d'autres fichiers importés.
  • La sortie JS de notre build Webpack sera située dans le répertoire dist/. J'appellerai ce fichier notre paquet js.
  • Nous utilisons Babel, et en particulier la configuration @babel/preset-env à transpiler notre code JS pour les anciens navigateurs.
  • Nous utilisons un plugin pour extraire tous les CSS référencés par les fichiers JS et les combiner en un seul endroit. Je l'appellerai notre paquet css.

Vous avez peut-être remarqué des noms de fichiers de paquets étranges '[name].[contenthash].ext'. Ils contiennent substitutions de noms de fichiers Webpack : [name] sera remplacé par le nom du point d'entrée (dans notre cas, ce game), et [contenthash] sera remplacé par un hachage du contenu du fichier. Nous le faisons pour optimiser le projet pour le hachage - vous pouvez demander aux navigateurs de mettre en cache nos packages JS indéfiniment, car si un package change, alors son nom de fichier change également (changements contenthash). Le résultat final sera le nom du fichier de vue game.dbeee76e91a97d0c7207.js.

Dossier webpack.common.js est le fichier de configuration de base que nous importons dans les configurations de développement et de projet fini. Voici un exemple de configuration de développement :

webpack.dev.js

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

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

Pour plus d'efficacité, nous utilisons dans le processus de développement webpack.dev.js, et passe à webpack.prod.jspour optimiser la taille des packages lors du déploiement en production.

Paramètre local

Je recommande d'installer le projet sur une machine locale afin que vous puissiez suivre les étapes répertoriées dans cet article. La configuration est simple : tout d'abord, le système doit avoir installé Nœud и NPM. Ensuite, vous devez faire

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

et vous êtes prêt à partir ! Pour démarrer le serveur de développement, exécutez simplement

$ npm run develop

et allez dans le navigateur Web localhost: 3000. Le serveur de développement reconstruira automatiquement les packages JS et CSS à mesure que le code change – actualisez simplement la page pour voir toutes les modifications !

3. Points d'entrée des clients

Passons au code du jeu lui-même. Nous avons d'abord besoin d'une page index.html, lors de la visite du site, le navigateur le chargera en premier. Notre page sera assez simple :

index.html

Un exemple de jeu .io  JOUER

Cet exemple de code a été légèrement simplifié pour plus de clarté, et je ferai de même avec de nombreux autres exemples de publication. Le code complet peut toujours être consulté sur Github.

Nous avons:

  • Élément de canevas HTML5 (<canvas>) que nous utiliserons pour rendre le jeu.
  • <link> pour ajouter notre package CSS.
  • <script> pour ajouter notre package Javascript.
  • Menu principal avec nom d'utilisateur <input> et le bouton PLAY (<button>).

Après avoir chargé la page d'accueil, le navigateur commencera à exécuter le code Javascript, à partir du fichier JS du point d'entrée : 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);
  };
});

Cela peut paraître compliqué, mais il ne se passe pas grand-chose ici :

  1. Importation de plusieurs autres fichiers JS.
  2. Importation CSS (donc Webpack sait les inclure dans notre package CSS).
  3. Запуск connect() pour établir une connexion avec le serveur et exécuter downloadAssets() pour télécharger les images nécessaires au rendu du jeu.
  4. Après avoir terminé l'étape 3 le menu principal est affiché (playMenu).
  5. Définition du gestionnaire pour appuyer sur le bouton "PLAY". Lorsque vous appuyez sur le bouton, le code initialise le jeu et indique au serveur que nous sommes prêts à jouer.

La principale "viande" de notre logique client-serveur se trouve dans les fichiers importés par le fichier index.js. Nous allons maintenant les considérer tous dans l'ordre.

4. Échange de données clients

Dans ce jeu, nous utilisons une bibliothèque bien connue pour communiquer avec le serveur socket.io. Socket.io a un support natif WebSockets, qui sont bien adaptés à la communication bidirectionnelle : nous pouvons envoyer des messages au serveur и le serveur peut nous envoyer des messages sur la même connexion.

Nous aurons un fichier src/client/networking.jsqui s'occupera de tous communication avec le serveur :

réseau.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);
};

Ce code a également été légèrement raccourci pour plus de clarté.

Il y a trois actions principales dans ce fichier :

  • Nous essayons de nous connecter au serveur. connectedPromise autorisé uniquement lorsque nous avons établi une connexion.
  • Si la connexion réussit, nous enregistrons les fonctions de rappel (processGameUpdate() и onGameOver()) pour les messages que nous pouvons recevoir du serveur.
  • Nous exportons play() и updateDirection()afin que d'autres fichiers puissent les utiliser.

5. Rendu client

Il est temps d'afficher l'image à l'écran !

…mais avant de pouvoir faire cela, nous devons télécharger toutes les images (ressources) nécessaires à cet effet. Écrivons un gestionnaire de ressources :

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

La gestion des ressources n’est pas si difficile à mettre en œuvre ! L'idée principale est de stocker un objet assets, qui liera la clé du nom de fichier à la valeur de l'objet Image. Lorsque la ressource est chargée, nous la stockons dans un objet assets pour un accès rapide à l'avenir. Quand chaque ressource individuelle sera-t-elle autorisée à être téléchargée (c'est-à-dire : tous ressources), nous permettons downloadPromise.

Après avoir téléchargé les ressources, vous pouvez commencer le rendu. Comme dit précédemment, pour dessiner sur une page web, on utilise HTML5 Canvas (<canvas>). Notre jeu est assez simple, il suffit donc de dessiner ce qui suit :

  1. fond
  2. Vaisseau du joueur
  3. Autres joueurs dans le jeu
  4. Coquillages

Voici les extraits importants src/client/render.js, qui restituent exactement les quatre éléments répertoriés ci-dessus :

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

Ce code est également raccourci pour plus de clarté.

render() est la fonction principale de ce fichier. startRendering() и stopRendering() contrôler l’activation de la boucle de rendu à 60 FPS.

Implémentations concrètes de fonctions d'aide au rendu individuelles (par ex. renderBullet()) ne sont pas si importants, mais voici un exemple simple :

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

Notez que nous utilisons la méthode getAsset(), ce qui a déjà été vu dans asset.js!

Si vous souhaitez en savoir plus sur d'autres assistants de rendu, lisez le reste. src/client/render.js.

6. Commentaires des clients

Il est temps de créer un jeu jouable! Le schéma de contrôle sera très simple : pour changer la direction du mouvement, vous pouvez utiliser la souris (sur un ordinateur) ou toucher l'écran (sur un appareil mobile). Pour mettre en œuvre cela, nous allons enregistrer Écouteurs d'événements pour les événements Mouse et Touch.
Je m'occuperai de tout ça src/client/input.js:

entrée.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() sont des écouteurs d'événements qui appellent updateDirection() (de networking.js) lorsqu'un événement d'entrée se produit (par exemple, lorsque la souris est déplacée). updateDirection() gère la messagerie avec le serveur, qui gère l'événement d'entrée et met à jour l'état du jeu en conséquence.

7. Statut du client

Cette section est la plus difficile de la première partie du post. Ne vous découragez pas si vous ne le comprenez pas la première fois que vous le lisez ! Vous pouvez même l'ignorer et y revenir plus tard.

La dernière pièce du puzzle nécessaire pour compléter le code client/serveur est Etat. Vous vous souvenez de l'extrait de code de la section Rendu client ?

rendre.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() devrait pouvoir nous donner l'état actuel du jeu dans le client à tout moment en fonction des mises à jour reçues du serveur. Voici un exemple de mise à jour de jeu que le serveur peut envoyer :

{
  "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
    }
  ]
}

Chaque mise à jour du jeu contient cinq champs identiques :

  • t: Horodatage du serveur indiquant quand cette mise à jour a été créée.
  • me: Informations sur le joueur recevant cette mise à jour.
  • autres: Un ensemble d'informations sur les autres joueurs participant au même jeu.
  • balles: un ensemble d'informations sur les projectiles du jeu.
  • classement: Données actuelles du classement. Dans cet article, nous ne les considérerons pas.

7.1 État du client naïf

Implémentation naïve getCurrentState() ne peut renvoyer directement que les données de la mise à jour du jeu la plus récente reçue.

naïf-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Agréable et clair ! Mais si seulement c'était aussi simple. L'une des raisons pour lesquelles cette mise en œuvre est problématique : il limite la fréquence d'images du rendu à la fréquence d'horloge du serveur.

Fréquence d'images: nombre de frames (c'est-à-dire d'appels render()) par seconde, ou FPS. Les jeux s'efforcent généralement d'atteindre au moins 60 FPS.

Taux de tick: La fréquence à laquelle le serveur envoie les mises à jour du jeu aux clients. Il est souvent inférieur au frame rate. Dans notre jeu, le serveur fonctionne à une fréquence de 30 cycles par seconde.

Si nous rendons simplement la dernière mise à jour du jeu, alors le FPS ne dépassera jamais 30, car nous ne recevons jamais plus de 30 mises à jour par seconde du serveur. Même si nous appelons render() 60 fois par seconde, la moitié de ces appels redessineront simplement la même chose, sans rien faire. Un autre problème avec l'implémentation naïve est qu'elle sujet aux retards. Avec une vitesse Internet idéale, le client recevra une mise à jour du jeu exactement toutes les 33 ms (30 par seconde) :

Création d'un jeu Web .io multijoueur
Malheureusement, rien n'est parfait. Une image plus réaliste serait :
Création d'un jeu Web .io multijoueur
L’implémentation naïve est pratiquement le pire des cas en matière de latence. Si une mise à jour du jeu est reçue avec un délai de 50 ms, alors stands de clients 50 ms supplémentaires car l'état du jeu est toujours celui de la mise à jour précédente. Vous pouvez imaginer à quel point cela est inconfortable pour le joueur : un freinage arbitraire rendra le jeu saccadé et instable.

7.2 État client amélioré

Nous apporterons quelques améliorations à l’implémentation naïve. Tout d'abord, nous utilisons délai de rendu pendant 100 ms. Cela signifie que l'état "actuel" du client sera toujours en retard de 100 ms par rapport à l'état du jeu sur le serveur. Par exemple, si l'heure sur le serveur est 150, le client affichera l'état dans lequel se trouvait le serveur au moment 50:

Création d'un jeu Web .io multijoueur
Cela nous donne un tampon de 100 ms pour survivre aux temps de mise à jour imprévisibles du jeu :

Création d'un jeu Web .io multijoueur
La récompense pour cela sera permanente décalage d'entrée pendant 100 ms. Il s'agit d'un sacrifice mineur pour un gameplay fluide : la plupart des joueurs (en particulier les joueurs occasionnels) ne remarqueront même pas ce retard. Il est beaucoup plus facile pour les gens de s'adapter à une latence constante de 100 ms que de jouer avec une latence imprévisible.

On peut également utiliser une autre technique appelée prédiction côté client, qui fait un bon travail en réduisant la latence perçue, mais ne sera pas abordé dans cet article.

Une autre amélioration que nous utilisons est interpolation linéaire. En raison du décalage de rendu, nous avons généralement au moins une mise à jour en avance sur l'heure actuelle dans le client. Lorsqu'on l'appelle getCurrentState(), nous pouvons exécuter interpolation linéaire entre les mises à jour du jeu juste avant et après l'heure actuelle dans le client :

Création d'un jeu Web .io multijoueur
Cela résout le problème de fréquence d'images : nous pouvons désormais restituer des images uniques à n'importe quelle fréquence d'images souhaitée !

7.3 Implémentation de l'état client amélioré

Exemple de mise en œuvre dans src/client/state.js utilise à la fois le décalage de rendu et l'interpolation linéaire, mais pas pour longtemps. Divisons le code en deux parties. Voici la première:

état.js partie 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;
}

La première étape consiste à comprendre ce que currentServerTime(). Comme nous l'avons vu précédemment, chaque mise à jour du jeu inclut un horodatage du serveur. Nous souhaitons utiliser la latence de rendu pour restituer l'image 100 ms derrière le serveur, mais nous ne connaîtrons jamais l'heure actuelle sur le serveur, car nous ne pouvons pas savoir combien de temps il a fallu pour que les mises à jour nous parviennent. Internet est imprévisible et sa vitesse peut varier considérablement !

Pour contourner ce problème, nous pouvons utiliser une approximation raisonnable : nous faire comme si la première mise à jour était arrivée instantanément. Si cela était vrai, alors nous connaîtrions l’heure du serveur à ce moment précis ! Nous stockons l'horodatage du serveur dans firstServerTimestamp et gardons notre local (client) horodatage au même moment dans gameStart.

Oh, attendez. Ne devrait-il pas être temps du serveur = temps du client ? Pourquoi faisons-nous la distinction entre « horodatage du serveur » et « horodatage du client » ? C'est une excellente question! Il s’avère que ce n’est pas la même chose. Date.now() renverra des horodatages différents dans le client et le serveur, et cela dépend de facteurs locaux à ces machines. Ne présumez jamais que les horodatages seront les mêmes sur toutes les machines.

Maintenant, nous comprenons ce que signifie currentServerTime(): ça revient l'horodatage du serveur de l'heure de rendu actuelle. En d'autres termes, il s'agit de l'heure actuelle du serveur (firstServerTimestamp <+ (Date.now() - gameStart)) moins le délai de rendu (RENDER_DELAY).

Voyons maintenant comment nous gérons les mises à jour du jeu. Lorsqu'il est reçu du serveur de mise à jour, il est appelé processGameUpdate()et nous enregistrons la nouvelle mise à jour dans un tableau gameUpdates. Ensuite, pour vérifier l'utilisation de la mémoire, nous supprimons toutes les anciennes mises à jour avant mise à jour de baseparce que nous n'en avons plus besoin.

Qu'est-ce qu'une « mise à jour de base » ? Ce la première mise à jour que nous trouvons en revenant en arrière par rapport à l'heure actuelle du serveur. Vous vous souvenez de ce schéma ?

Création d'un jeu Web .io multijoueur
La mise à jour du jeu directement à gauche de « Client Render Time » est la mise à jour de base.

A quoi sert la mise à jour de base ? Pourquoi pouvons-nous laisser les mises à jour à la ligne de base ? Pour comprendre cela, regardons finalement envisager la mise en œuvre getCurrentState():

état.js partie 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),
    };
  }
}

Nous traitons trois cas :

  1. base < 0 signifie qu'il n'y a pas de mises à jour avant l'heure de rendu actuelle (voir l'implémentation ci-dessus getBaseUpdate()). Cela peut se produire dès le début du jeu en raison du décalage de rendu. Dans ce cas, nous utilisons la dernière mise à jour reçue.
  2. base est la dernière mise à jour dont nous disposons. Cela peut être dû à un retard du réseau ou à une mauvaise connexion Internet. Dans ce cas, nous utilisons également la dernière mise à jour dont nous disposons.
  3. Nous avons une mise à jour avant et après le temps de rendu actuel, nous pouvons donc interpoler!

Tout ce qui reste dedans state.js est une implémentation d'interpolation linéaire qui est une mathématique simple (mais ennuyeuse). Si vous souhaitez l'explorer vous-même, ouvrez state.js sur Github.

Partie 2. Serveur backend

Dans cette partie, nous examinerons le backend Node.js qui contrôle notre Exemple de jeu .io.

1. Point d'entrée du serveur

Pour gérer le serveur Web, nous utiliserons un framework Web populaire pour Node.js appelé Express. Il sera configuré par notre fichier de point d'entrée du serveur src/server/server.js:

serveur.js partie 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}`);

Vous vous souvenez que dans la première partie nous avons discuté de Webpack ? C'est ici que nous utiliserons nos configurations Webpack. Nous les utiliserons de deux manières :

  • À utiliser webpack-dev-middleware pour reconstruire automatiquement nos packages de développement, ou
  • transférer le dossier statiquement dist/, dans lequel Webpack écrira nos fichiers après la version de production.

Une autre tâche importante server.js est de configurer le serveur socket.ioqui se connecte simplement au serveur Express :

serveur.js partie 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);
});

Après avoir établi avec succès une connexion socket.io au serveur, nous configurons des gestionnaires d'événements pour le nouveau socket. Les gestionnaires d'événements gèrent les messages reçus des clients en déléguant à un objet singleton game:

serveur.js partie 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);
}

Nous créons un jeu .io, nous n'avons donc besoin que d'une seule copie Game ("Jeu") - tous les joueurs jouent dans la même arène ! Dans la section suivante, nous verrons comment fonctionne cette classe. Game.

2. Serveurs de jeux

classe Game contient la logique la plus importante côté serveur. Il a deux missions principales : gestion des joueurs и simulation de jeu.

Commençons par la première tâche, la gestion des joueurs.

game.js partie 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);
    }
  }

  // ...
}

Dans ce jeu, nous identifierons les joueurs par terrain id leur socket socket.io (si vous êtes confus, revenez à server.js). Socket.io attribue à chaque socket un nom unique idnous n'avons donc pas à nous en soucier. Je l'appelerai Identifiant du joueur.

Dans cet esprit, explorons les variables d'instance dans une classe Game:

  • sockets est un objet qui lie l'ID du joueur au socket associé au joueur. Cela nous permet d'accéder aux sockets par leurs identifiants de joueur en temps constant.
  • players est un objet qui lie l'ID du joueur à l'objet code> Player

bullets est un tableau d'objets Bullet, qui n'a pas d'ordre défini.
lastUpdateTime est l'horodatage de la dernière mise à jour du jeu. Nous verrons bientôt comment il est utilisé.
shouldSendUpdate est une variable auxiliaire. Nous verrons également son utilisation prochainement.
Méthodes addPlayer(), removePlayer() и handleInput() pas besoin d'expliquer, ils sont utilisés dans server.js. Si vous avez besoin de vous rafraîchir la mémoire, remontez un peu plus haut.

Dernière ligne constructor() commence cycle de mise à jour jeux (avec une fréquence de 60 mises à jour/s) :

game.js partie 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éthode update() contient peut-être l’élément le plus important de la logique côté serveur. Voici ce qu'il fait, dans l'ordre :

  1. Calcule combien de temps dt passé depuis le dernier update().
  2. Actualise chaque projectile et les détruit si nécessaire. Nous verrons plus tard l’implémentation de cette fonctionnalité. Pour l'instant, il nous suffit de savoir que bullet.update() retourne truesi le projectile devait être détruit (il sortit de l'arène).
  3. Met à jour chaque joueur et génère un projectile si nécessaire. Nous verrons également cette implémentation plus tard - player.update() peut renvoyer un objet Bullet.
  4. Vérifie les collisions entre les projectiles et les joueurs avec applyCollisions(), qui renvoie une série de projectiles qui frappent les joueurs. Pour chaque projectile renvoyé, on augmente les points du joueur qui l'a tiré (en utilisant player.onDealtDamage()) puis retirez le projectile du tableau bullets.
  5. Avertit et détruit tous les joueurs tués.
  6. Envoie une mise à jour du jeu à tous les joueurs chaque seconde fois où j'ai appelé update(). Cela nous aide à garder une trace de la variable auxiliaire mentionnée ci-dessus. shouldSendUpdate. Comme update() appelé 60 fois/s, nous envoyons des mises à jour du jeu 30 fois/s. Ainsi, fréquence d'horloge l'horloge du serveur est de 30 horloges/s (nous avons parlé des fréquences d'horloge dans la première partie).

Pourquoi envoyer uniquement des mises à jour de jeu à travers le temps ? Pour enregistrer la chaîne. 30 mises à jour de jeu par seconde, c'est beaucoup !

Pourquoi ne pas simplement appeler update() 30 fois par seconde ? Pour améliorer la simulation de jeu. Le plus souvent appelé update(), plus la simulation de jeu sera précise. Mais ne vous laissez pas trop emporter par le nombre de défis. update(), car il s'agit d'une tâche coûteuse en termes de calcul - 60 par seconde suffisent.

Le reste de la classe Game se compose de méthodes d'assistance utilisées dans update():

game.js partie 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() assez simple : il trie les joueurs par score, prend les cinq premiers et renvoie le nom d'utilisateur et le score de chacun.

createUpdate() utilisé dans update() pour créer des mises à jour de jeu qui sont distribuées aux joueurs. Sa tâche principale est d'appeler des méthodes serializeForUpdate()mis en œuvre pour les cours Player и Bullet. Notez qu'il transmet uniquement des données à chaque joueur concernant la plus proche joueurs et projectiles - il n'est pas nécessaire de transmettre des informations sur les objets de jeu éloignés du joueur !

3. Objets de jeu sur le serveur

Dans notre jeu, les projectiles et les joueurs sont en réalité très similaires : ce sont des objets de jeu abstraits, ronds et mobiles. Pour profiter de cette similitude entre joueurs et projectiles, commençons par implémenter la classe de base Object:

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

Il ne se passe rien de compliqué ici. Cette classe sera un bon point d’ancrage pour l’extension. Voyons comment la classe Bullet utilise Object:

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

exécution Bullet très court! Nous avons ajouté à Object uniquement les extensions suivantes :

  • Utiliser un package shortide pour la génération aléatoire id coquille.
  • Ajouter un champ parentIDafin que vous puissiez suivre le joueur qui a créé ce projectile.
  • Ajout d'une valeur de retour à update()qui est égal truesi le projectile est en dehors de l'arène (vous vous souvenez que nous en avons parlé dans la dernière section ?).

Passons à Player:

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

Les joueurs sont plus complexes que les projectiles, donc quelques champs supplémentaires devraient être stockés dans cette classe. Sa méthode update() fait beaucoup de travail, notamment, renvoie le projectile nouvellement créé s'il n'en reste plus fireCooldown (vous vous souvenez que nous en avons parlé dans la section précédente ?). Cela étend également la méthode serializeForUpdate(), car nous devons inclure des champs supplémentaires pour le joueur dans la mise à jour du jeu.

Avoir une classe de base Object - une étape importante pour éviter de répéter le code. Par exemple, pas de cours Object chaque objet de jeu doit avoir la même implémentation distanceTo(), et copier-coller toutes ces implémentations sur plusieurs fichiers serait un cauchemar. Cela devient particulièrement important pour les grands projets.quand le nombre d'expansion Object les classes s'agrandissent.

4. Détection des collisions

Il ne nous reste plus qu'à reconnaître quand les projectiles touchent les joueurs ! Rappelez-vous ce morceau de code de la méthode update() en classe Game:

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

    // ...
  }
}

Nous devons mettre en œuvre la méthode applyCollisions(), qui renvoie tous les projectiles qui ont touché les joueurs. Heureusement, ce n'est pas si difficile à faire parce que

  • Tous les objets en collision sont des cercles, ce qui constitue la forme la plus simple pour mettre en œuvre la détection de collision.
  • Nous avons déjà une méthode distanceTo(), que nous avons implémenté dans la section précédente de la classe Object.

Voici à quoi ressemble notre implémentation de détection de collision :

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

Cette simple détection de collision est basée sur le fait que deux cercles entrent en collision si la distance entre leurs centres est inférieure à la somme de leurs rayons. Voici le cas où la distance entre les centres de deux cercles est exactement égale à la somme de leurs rayons :

Création d'un jeu Web .io multijoueur
Il y a quelques autres aspects à considérer ici :

  • Le projectile ne doit pas toucher le joueur qui l'a créé. Ceci peut être réalisé en comparant bullet.parentID с player.id.
  • Le projectile ne doit frapper qu'une seule fois dans le cas limite où plusieurs joueurs entrent en collision en même temps. Nous allons résoudre ce problème en utilisant l'opérateur break: dès que le joueur entrant en collision avec le projectile est retrouvé, on arrête la recherche et on passe au projectile suivant.

fin

C'est tout! Nous avons couvert tout ce que vous devez savoir pour créer un jeu Web .io. Et après? Créez votre propre jeu .io !

Tous les exemples de code sont open source et publiés sur Github.

Source: habr.com

Ajouter un commentaire