Créer un jeu web .io multijoueur

image

Sorti en 2015, Agar.io est devenu l'ancêtre du nouveau genre de jeux .io , dont la popularité a depuis considérablement augmenté. La croissance de la popularité des jeux .io que j'ai connue sur moi-même: au cours des trois dernières années, j'ai créé et vendu deux jeux de ce genre. .

Dans le cas où vous n'avez jamais entendu parler de tels jeux auparavant: ce sont des jeux Web multijoueurs gratuits auxquels il est facile de participer (aucun compte requis). Habituellement, ils poussent de nombreux joueurs adverses dans la même arène. D'autres jeux célèbres du genre .io sont: Slither.io et Diep.io.

Dans cet article, nous découvrirons comment créer un jeu .io à partir de zéro . Pour cela, seule la connaissance de Javascript sera suffisante: vous devez comprendre des choses telles que la syntaxe ES6 , le this et Promises . Même si vous ne connaissez pas parfaitement Javascript, vous pouvez toujours comprendre la plupart des messages.

Exemple de jeu .io


Pour vous aider à apprendre, nous nous référerons à un exemple de jeu .io . Essayez de jouer!


Le jeu est assez simple: vous contrôlez un navire dans une arène où il y a d'autres joueurs. Votre vaisseau tire automatiquement des obus et vous essayez de frapper d'autres joueurs, tout en évitant leurs obus.

1. Aperçu / structure du projet


Je recommande de télécharger le code source d'un exemple de jeu afin que vous puissiez me suivre.

L'exemple utilise les éléments suivants:

  • Express est le cadre Web le plus populaire pour Node.js 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 est un gestionnaire de modules. Vous pouvez découvrir pourquoi utiliser Webpack ici .

Voici à quoi ressemble la structure du répertoire du projet:

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

public /


Tout dans le dossier public/ sera transmis statiquement par le serveur. public/assets/ contient les images utilisées par notre projet.

src /


Tout le code source se trouve dans le dossier src/ . Les noms client/ et server/ parlent d'eux-mêmes et shared/ contient un fichier constant importé par le client et le serveur.

2. Assemblages / paramètres du projet


Comme indiqué ci-dessus, nous utilisons le gestionnaire de modules Webpack pour construire le projet. Jetons un œ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 plus importantes ici sont les lignes suivantes:

  • src/client/index.js est le point d'entrée du client Javascript (JS). Webpack démarre à partir d'ici et recherche récursivement d'autres fichiers importés.
  • Le JS de sortie de notre assemblage Webpack sera situé dans le répertoire dist/ . J'appellerai ce fichier notre package JS .
  • Nous utilisons Babel , et en particulier la configuration @ babel / preset-env pour transpiler notre code JS pour les navigateurs plus anciens.
  • Nous utilisons le 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 package CSS .

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

Le fichier webpack.common.js est le fichier de configuration de base que nous importons dans les configurations de développement et de projet terminées. Par exemple, une 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 webpack.dev.js dans le webpack.dev.js développement et webpack.prod.js vers webpack.prod.js pour optimiser la taille des packages lorsqu'ils sont déployés en production.

Réglage 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, Node et NPM doivent être installés sur le système. Ensuite, vous devez effectuer

 $ 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 accédez à l' hôte local: 3000 dans un navigateur Web. 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. Tout d'abord, nous avons besoin de la page index.html , lorsque vous visitez le site, le navigateur la charge d'abord. Notre page sera assez simple:

index.html

  <! DOCTYPE html>
 <html>
 <head>
   <title> Un exemple de jeu .io </title>
   <link type = "text / css" rel = "stylesheet" href = "/ game.bundle.css">
 </head>
 <body>
   <canvas id = "game-canvas"> </canvas>
   <script async src = "/ game.bundle.js"> </script>
   <div id = "play-menu" class = "hidden">
     <input type = "text" id = "username-input" placeholder = "Username" />
     <button id = "play-button"> PLAY </button>
   </div>
 </body>
 </html> 

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

Nous avons:

  • <canvas> HTML5 Canvas ( <canvas> ) que nous utiliserons pour rendre le jeu.
  • <link> pour ajouter notre package CSS.
  • <script> pour ajouter notre package Javascript.
  • Le menu principal avec le nom d'utilisateur <input> et le <button> "PLAY" ( <button> ).

Après avoir chargé la page d'accueil, le code Javascript commencera à s'exécuter dans le navigateur, en commençant par le 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 sembler compliqué, mais en réalité, peu d'actions se produisent ici:

  1. Importez plusieurs autres fichiers JS.
  2. Importez du CSS (afin que Webpack sache les inclure dans notre package CSS).
  3. Exécutez connect() pour établir une connexion au serveur et exécutez downloadAssets() pour télécharger les images nécessaires au rendu du jeu.
  4. Une fois l'étape 3 terminée , le menu principal ( playMenu ) playMenu .
  5. Configuration du gestionnaire pour appuyer sur le bouton PLAY. Lorsque le bouton est enfoncé, le code initialise le jeu et indique au serveur que nous sommes prêts à jouer.

La «viande» principale de notre logique client-serveur réside dans les fichiers importés par le fichier index.js . Nous allons maintenant les considérer tous en ordre.

4. Échange de données clients


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

Nous aurons un fichier src/client/networking.js qui gérera toutes les communications avec le serveur:

networking.js

 import io from 'socket.io-client'; import { processGameUpdate } from './state'; const Constants = require('../shared/constants'); const socket = io(`ws://${window.location.host}`); const connectedPromise = new Promise(resolve => { socket.on('connect', () => { console.log('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 est également légèrement réduit pour plus de clarté.

Ce fichier comporte trois actions principales:

  • Nous essayons de nous connecter au serveur. connectedPromise n'est autorisé que lorsque nous avons établi une connexion.
  • Si la connexion est établie avec succès, nous enregistrons les fonctions de rappel ( processGameUpdate() et onGameOver() ) pour les messages que nous pouvons recevoir du serveur.
  • Nous exportons play() et updateDirection() afin que d'autres fichiers puissent les utiliser.

5. Rendu client


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

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

assets.js

 const ASSET_NAMES = ['ship.svg', 'bullet.svg']; const assets = {}; const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset)); function downloadAsset(assetName) { return new Promise(resolve => { const asset = new Image(); asset.onload = () => { console.log(`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! Le point principal est de stocker l'objet d' assets , qui liera la clé du nom de fichier à la valeur de l'objet Image . Lorsque la ressource est chargée, nous l'enregistrons dans l'objet d' assets pour une récupération rapide à l'avenir. Lorsque le téléchargement sera autorisé pour chaque ressource individuelle (c'est-à-dire que toutes les ressources seront téléchargées), nous activons downloadPromise .

Après avoir téléchargé les ressources, vous pouvez commencer le rendu. Comme indiqué précédemment, nous utilisons HTML5 Canvas ( <canvas> ) pour dessiner sur la page Web. Notre jeu est assez simple, il nous suffit donc de dessiner uniquement les éléments suivants:

  1. Contexte
  2. Navire joueur
  3. Autres joueurs du jeu
  4. Coquillages

Voici les extraits importants de src/client/render.js qui rendent exactement les quatre points énumérés ci-dessus:

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

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

render() est la fonction principale de ce fichier. startRendering() et stopRendering() contrôlent l'activation du cycle de rendu à 60 FPS.

Les implémentations spécifiques des fonctions de rendu auxiliaires individuelles (par exemple, renderBullet() ) ne sont pas si importantes, mais voici un exemple simple:

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

Notez que nous utilisons la méthode getAsset() qui a été vue précédemment dans asset.js !

Si vous souhaitez explorer d'autres fonctions de rendu auxiliaires, lisez le reste de src / client / render.js .

6. Contribution du client


Il est temps de rendre le jeu jouable ! Le schéma de contrôle sera très simple: vous pouvez utiliser la souris (sur l'ordinateur) ou toucher l'écran (sur l'appareil mobile) pour changer la direction du mouvement. Pour implémenter cela, nous enregistrerons les écouteurs d'événements pour les événements Mouse and Touch.
src/client/input.js fera tout cela:

input.js

 import { updateDirection } from './networking'; function onMouseInput(e) { handleInput(e.clientX, e.clientY); } function onTouchInput(e) { const touch = e.touches[0]; handleInput(touch.clientX, touch.clientY); } function handleInput(x, y) { const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y); updateDirection(dir); } export function startCapturingInput() { window.addEventListener('mousemove', onMouseInput); window.addEventListener('touchmove', onTouchInput); } export function stopCapturingInput() { window.removeEventListener('mousemove', onMouseInput); window.removeEventListener('touchmove', onTouchInput); } 

onMouseInput() et onTouchInput() sont des écouteurs d'événements qui appellent updateDirection() (à partir de networking.js ) lorsqu'un événement d'entrée se produit (par exemple, lors du déplacement de la souris). updateDirection() est engagé dans la messagerie avec un serveur qui traite l'événement d'entrée et met à jour l'état du jeu en conséquence.

7. Condition du client


Cette section est la plus difficile dans la première partie de l'article. Ne vous découragez pas si vous ne le comprenez pas dès la première lecture! Vous pouvez même l'ignorer et y revenir plus tard.

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

render.js

 import { getCurrentState } from './state'; function render() { const { me, others, bullets } = getCurrentState(); // Do the rendering // ... } 

getCurrentState() devrait être en mesure de nous fournir à tout moment l'état actuel du jeu dans le client en fonction des mises à jour reçues du serveur. Voici un exemple de mise à jour de jeu qu'un 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 pour l'heure de création de cette mise à jour.
  • moi : informations sur le joueur qui reçoit cette mise à jour.
  • autres : un tableau d'informations sur les autres joueurs participant au même jeu.
  • balles : un tableau d'informations sur les obus du jeu.
  • leaderboard : données actuelles du leaderboard. Dans ce post, nous ne les prendrons pas en compte.

7.1 L'état naïf du client


L'implémentation naïve de getCurrentState() ne peut que renvoyer directement les données de la dernière mise à jour du jeu reçue.

naive-state.js

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

Beau et clair! Mais si tout était si simple. L'une des raisons pour lesquelles cette implémentation est problématique: elle limite la fréquence d'images du rendu à la fréquence de l'horloge du serveur .

Fréquence d'images: nombre d'images (c. render() Appels de render() ) par seconde, ou FPS. Les jeux ont généralement tendance à atteindre au moins 60 FPS.

Taux de tick : fréquence à laquelle le serveur envoie des mises à jour du jeu aux clients. Il est souvent inférieur à la fréquence d'images . 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 FPS ne pourra en fait jamais dépasser 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, ne faisant essentiellement rien. Un autre problème de mise en œuvre naïve est qu'elle est sujette à des retards . Avec une vitesse Internet idéale, le client recevra une mise à jour du jeu exactement toutes les 33 ms (30 par seconde):


Malheureusement, rien n'est parfait. Une image plus réaliste serait:

Une mise en œuvre naïve est pratiquement le pire des cas de retard. Si la mise à jour du jeu est reçue avec un retard de 50 ms, le client ralentit pendant 50 ms supplémentaires, car il restitue l'état du jeu à partir de la mise à jour précédente. Vous pouvez imaginer à quel point cela est gênant pour un joueur: en raison d'un freinage arbitraire, le jeu semblera instable et instable.

7.2 Statut client amélioré


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


Cela nous donne un tampon de 100 ms, nous permettant de survivre au temps imprévisible pour recevoir les mises à jour du jeu:


Le payer sera un retard d'entrée constant (décalage d'entrée) de 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 à un retard constant de 100 ms que de jouer avec un retard imprévisible.

Nous pouvons utiliser une autre technique appelée «prévisions côté client», qui fait un bon travail de réduction des retards perçus, mais elle ne sera pas considérée dans ce post.

Une autre amélioration que nous utilisons est l'interpolation linéaire . En raison des retards de rendu, nous dépassons généralement l'heure actuelle dans le client par au moins une mise à jour. Lorsque getCurrentState() est appelé, nous pouvons effectuer une interpolation linéaire entre les mises à jour du jeu immédiatement avant et après l'heure actuelle dans le client:


Cela résout le problème de la fréquence d'images: nous pouvons maintenant rendre des images uniques avec n'importe quelle fréquence dont nous avons besoin!

7.3 Implémentation du statut client amélioré


Un exemple d'implémentation dans src/client/state.js utilise à la fois le délai de rendu et l'interpolation linéaire, mais ce n'est pas pour longtemps. Décomposons le code en deux parties. Voici le premier:

state.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 fait currentServerTime() . Comme nous l'avons vu précédemment, un horodatage du serveur est inclus dans chaque mise à jour du jeu. Nous voulons utiliser le délai de rendu pour rendre 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 les mises à jour nous sont parvenues. Internet est imprévisible et sa vitesse peut varier considérablement!

Pour contourner ce problème, vous pouvez utiliser une approximation raisonnable: nous prétendrons que la première mise à jour est arrivée instantanément . Si cela était vrai, nous saurions l'heure du serveur à ce moment particulier! Nous enregistrons l'horodatage du serveur dans firstServerTimestamp et enregistrons notre horodatage local (client) en même temps dans gameStart .

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

Nous comprenons maintenant ce que fait currentServerTime() : il retourne l' horodatage du serveur pour le temps de rendu actuel .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'une mise à jour est reçue du serveur, elle est appelée 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 de la mise à jour de base , car nous n'en avons plus besoin.

Qu'est-ce qu'une "mise à jour de base"? Il s'agit de la première mise à jour que nous constatons en reculant par rapport à l'heure actuelle du serveur . Rappelez-vous ce circuit?


La mise à jour du jeu directement à gauche de Client Render Time est une mise à jour de base.

À quoi sert la mise à jour de base? Pourquoi pouvons-nous supprimer les mises à jour de la base? Pour comprendre cela, regardons enfin l'implémentation getCurrentState():

state.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 < 0signifie qu'il n'y a pas de mise à jour du temps de rendu actuel (voir implémentation ci-dessus getBaseUpdate()). Cela peut se produire dès le début du jeu en raison de retards de rendu. Dans ce cas, nous utilisons la mise à jour la plus récente.
  2. baseCeci est la dernière mise à jour que nous avons. Cela peut être dû à une latence du réseau ou à une mauvaise connexion Internet. Dans ce cas, nous utilisons également la dernière mise à jour que nous avons.
  3. Nous avons une mise à jour avant et après le temps de rendu actuel, vous pouvez donc interpoler !

Il ne reste plus qu'à state.jsimplémenter l'interpolation linéaire, qui est un calcul simple (mais ennuyeux). Si vous voulez l'apprendre vous-même, ouvrez-le state.jssur Github .

Partie 2. Serveur principal


Dans cette partie, nous allons examiner le backend Node.js qui exécute notre exemple de jeu .io .

1. Point d'entrée du serveur


Pour contrôler le serveur Web, nous utiliserons le cadre Web populaire pour Node.js appelé Express . Il sera configuré par notre fichier de point d'entrée de serveur src/server/server.js:

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

Rappelez-vous que dans la première partie, nous avons discuté de Webpack? C'est là que nous utiliserons nos configurations Webpack. Nous les appliquerons de deux manières:

  • Utilisez webpack-dev-middleware pour reconstruire automatiquement nos packages de développement, ou
  • Transférez statiquement le dossier dist/dans lequel Webpack écrira nos fichiers après l'assemblage de la production.

Une autre tâche importante server.jsconsiste à configurer le serveur socket.io , qui se connecte simplement au serveur Express:

server.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 réussi à établir une connexion socket.io avec le serveur, nous configurons les gestionnaires d'événements pour le nouveau socket. Les gestionnaires d'événements traitent les messages reçus des clients par délégation à l'objet singleton game:

server.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 du genre .io, nous n'avons donc besoin que d'une seule instance Game(«Jeu») - tous les joueurs jouent dans la même arène! Dans la section suivante, nous verrons comment fonctionne cette classe Game.

2. Serveur de jeu


La classe Gamecontient la logique côté serveur la plus importante. Il a deux tâches principales: la gestion des joueurs et la simulation de jeu .

Commençons par la première tâche - avec 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 allons identifier les joueurs par le champ de idleur socket socket.io (si vous êtes confus, revenez à server.js). Socket.io lui-même attribue un socket unique à chaque socket id, nous n'avons donc pas à nous en préoccuper. Je vais appeler son ID de joueur .

Dans cet esprit, examinons les variables d'instance dans la classe Game:

  • socketsEst un objet qui lie l'ID du lecteur à la socket associée au lecteur. Il nous permet d'accéder aux sockets par leur identifiant de joueur pendant un temps constant.
  • players Est un objet qui lie un ID joueur à un code> Objet joueur

bulletsEst un tableau d'objets Bulletqui n'a pas d'ordre spécifique.
lastUpdateTime- Il s'agit de l'horodatage de la dernière mise à jour du jeu. Bientôt, nous verrons comment il est utilisé.
shouldSendUpdateEst une variable auxiliaire. Nous verrons bientôt son utilisation.
Les méthodes addPlayer(), removePlayer()et handleInput()pas besoin d'expliquer, elles sont utilisées dans server.js. Si vous avez besoin de rafraîchir votre mémoire, remontez un peu plus haut.

La dernière ligne constructor()démarre le cycle de mise à jour du jeu (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; } } // ... } 

La méthode update()contient probablement la partie la plus importante de la logique côté serveur. Dans l'ordre, nous listons tout ce qu'il fait:

  1. Calcule le temps dtécoulé depuis le dernier update().
  2. . . , bullet.update() true , ( ).
  3. . — player.update() Bullet .
  4. applyCollisions() , , . , ( player.onDealtDamage() ), bullets .
  5. .
  6. update() . shouldSendUpdate . update() 60 /, 30 /. , 30 / ( ).

? . 30 – !

Pourquoi alors ne pas appeler update()30 fois par seconde? Pour améliorer la simulation du jeu. Le plus souvent appelé update(), le plus précis sera la simulation du jeu. Mais ne vous laissez pas trop emporter par le nombre d'appels update(), car c'est une tâche coûteuse en calcul - 60 par seconde est assez suffisant.

Le reste de la classe se Gamecompose 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 le nombre de points, prend les cinq meilleurs et revient pour chaque nom d'utilisateur et score.

createUpdate()utilisé update()pour créer des mises à jour de jeu qui sont transmises aux joueurs. Sa tâche principale est d'appeler les méthodes serializeForUpdate()implémentées pour Playeret les classes Bullet. Notez qu'il transfère à chaque joueur uniquement les informations sur les joueurs et les obus les plus proches - il n'est pas nécessaire de transmettre des informations sur les objets de jeu situés loin du joueur!

3. Objets de jeu sur le serveur


Dans notre jeu, les coquilles et les joueurs sont en fait très similaires: ce sont des objets de jeu circulaires abstraits. Pour profiter de cette similitude des joueurs et des shells, commençons par l'implémentation de la classe de base Object:

object.js

 class Object { constructor(id, x, y, dir, speed) { this.id = id; this.x = x; this.y = y; this.direction = dir; this.speed = speed; } update(dt) { this.x += dt * this.speed * Math.sin(this.direction); this.y -= dt * this.speed * Math.cos(this.direction); } distanceTo(object) { const dx = this.x - object.x; const dy = this.y - object.y; return Math.sqrt(dx * dx + dy * dy); } setDirection(dir) { this.direction = dir; } serializeForUpdate() { return { id: this.id, x: this.x, y: this.y, }; } } 

Rien de compliqué ne se passe ici. Cette classe sera un bon point de référence pour l'expansion. Voyons comment la classe Bulletutilise 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; } } 

L'implémentation est Bullettrès courte! Nous avons ajouté Objectuniquement aux extensions suivantes:

  • Utiliser un package shortid pour générer aléatoirement un idprojectile.
  • Ajout d'un champ parentIDpour suivre le joueur qui a créé ce projectile.
  • Ajouter une valeur de retour à update(), qui est égale truesi le projectile est en dehors de l'arène (rappelez-vous, nous en avons parlé dans la dernière section?).

Passons à Player:

player.js

 const ObjectClass = require('./object'); const Bullet = require('./bullet'); const Constants = require('../shared/constants'); class Player extends ObjectClass { constructor(id, username, x, y) { super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED); this.username = username; this.hp = Constants.PLAYER_MAX_HP; this.fireCooldown = 0; this.score = 0; } // 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 obus, donc quelques champs supplémentaires devraient être stockés dans cette classe. Sa méthode update()fait beaucoup de travail, en particulier, renvoie le shell nouvellement créé s'il n'est pas laissé fireCooldown(rappelez-vous, nous en avons parlé dans la section précédente?). Il é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 Objectest une étape importante pour éviter la répétabilité du code . Par exemple, sans classe, Objectchaque objet de jeu devrait avoir la même implémentation distanceTo(), et la synchronisation copier-coller de toutes ces implémentations dans plusieurs fichiers serait un cauchemar. Cela devient particulièrement important pour les grands projets lorsque le nombre de Objectclasses d' extension augmente.

4. Reconnaissance des conflits


Il ne nous reste plus qu'à reconnaître quand les obus frappent les joueurs! Rappelez-vous ce morceau de code d'une méthode update()dans une classe Game:

game.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 implémenter une méthode applyCollisions()qui retourne tous les obus qui frappent les joueurs. Heureusement, ce n'est pas si difficile à faire, car

  • Tous les objets en collision sont des cercles, et c'est la figure la plus simple pour implémenter la reconnaissance des collisions.
  • Nous avons déjà une méthode distanceTo()que nous avons implémentée dans la section précédente de la classe Object.

Voici à quoi ressemble notre implémentation de reconnaissance des collisions:

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 reconnaissance 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:


Ici, vous devez examiner attentivement quelques aspects:

  • Le projectile ne doit pas tomber dans le joueur qui l'a créé. Ceci peut être réalisé en comparant bullet.parentIDavec player.id.
  • Le projectile ne doit frapper qu'une seule fois dans le cas extrême d'une collision simultanée avec plusieurs joueurs. Nous allons résoudre ce problème avec l'aide de l'opérateur break: dès qu'un joueur est entré en collision avec un projectile, nous arrêtons de chercher et passons au projectile suivant.

La fin


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

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

Source: https://habr.com/ru/post/fr450574/


All Articles