
Sortie en 2015 a inauguré un nouveau genre , dont la popularité a considérablement augmenté depuis. J'ai personnellement ressenti cette croissance de la popularité des jeux .io : au cours des trois dernières années, j'ai .
Au cas où vous n'auriez jamais entendu parler de ce type de jeux : ce sont des jeux web multijoueurs gratuits auxquels il est facile de participer (pas de compte requis). Généralement, ils mettent en compétition de nombreux joueurs sur une même arène. D'autres jeux célèbres du genre .io : et .
Dans cet article, nous allons voir comment créer un jeu .io à partir de zéro.Pour cela, il vous suffira de connaître Javascript : vous devez comprendre des concepts tels que la syntaxe , le mot-clé ceci et . Même si vous ne maîtrisez pas parfaitement Javascript, vous pourrez tout de même comprendre la majeure partie de l'article.
Exemple de jeu .io
Pour vous aider à apprendre, nous allons nous référer à Essayez d'y jouer !

Le jeu est assez simple : vous contrôlez un vaisseau sur une arène où se trouvent d'autres joueurs. Votre vaisseau tire automatiquement des projectiles et vous devez toucher les autres joueurs tout en évitant leurs projectiles.
1. Aperçu succinct / structure du projet
Je recommande de de l'exemple de jeu, afin que vous puissiez me suivre.
L'exemple utilise les éléments suivants :
- — le framework web le plus populaire pour Node.js, qui gère le serveur web du jeu.
- — une bibliothèque websocket pour l'échange de données entre le navigateur et le serveur.
- — un gestionnaire de modules. Vous pouvez lire pourquoi utiliser Webpack .
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.jspublic/
Tout dans le dossier public/ sera servi statiquement par le serveur. Dans public/assets/ se trouvent les images utilisées par notre projet.
src/
Tout le code source est dans le dossier src/. Noms client/ et server/ parle de lui-même, et shared/ contient un fichier de constantes, importé à la fois par le client et le serveur.
2. Assemblages / paramètres du projet
Comme mentionné précédemment, nous utilisons un gestionnaire de modules pour assembler 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 lignes les plus importantes ici sont les suivantes :
src/client/index.js— ceci est le point d'entrée du client Javascript (JS). Webpack commencera ici et cherchera de manière récursive d'autres fichiers importés.- Le JS de notre build Webpack sera situé dans le répertoire
dist/. Je vais appeler ce fichier notre paquet JS. - Nous utilisons , et en particulier la configuration pour transpiler notre code JS pour les anciens navigateurs.
- Nous utilisons un plugin pour extraire tous les CSS auxquels les fichiers JS font référence et pour les regrouper au même endroit. Je vais l'appeler notre paquet CSS.
Vous avez peut-être remarqué des noms de fichiers de paquet étranges '[name].[contenthash].ext'. Ils contiennent des Webpack : [name] sera remplacé par le nom du point d'entrée (dans notre cas, c'est game), tandis que [contenthash] sera remplacé par le hash du contenu du fichier. Nous faisons cela pour — nous pouvons dire aux navigateurs de mettre en cache indéfiniment nos paquets JS, car si le paquet change, son nom de fichier change aussi (le contenthash). Le résultat final sera un nom de fichier de ce type game.dbeee76e91a97d0c7207.js.
Le fichier webpack.common.js — c'est le fichier de configuration de base que nous importons dans les configurations de développement et de projet final. Voici, par exemple, la 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 l'efficacité, nous utilisons dans le processus de développement webpack.dev.js, et cela bascule sur webpack.prod.js, pour optimiser la taille des paquets lors du déploiement en production.
Configuration locale
Je recommande d'installer le projet sur une machine locale afin que vous puissiez suivre les étapes énumérées dans ce post. La configuration est simple : tout d'abord, vous devez avoir installé et . Ensuite, exécutez
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installEt vous êtes prêt à travailler ! Pour lancer le serveur de développement, il suffit d'exécuter
$ npm run developet d'aller dans le navigateur web à l'adresse . Le serveur de développement va automatiquement recompiler les paquets JS et CSS pendant que vous modifiez le code — il suffit de rafraîchir la page pour voir toutes les modifications !
3. Points d'entrée client
Passons au code du jeu. Pour commencer, nous aurons besoin d'une page index.html, qui sera chargée en premier lorsque le site sera visité. 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="Nom d'utilisateur" />
<button id="play-button">JOUER</button>
</div>
</body>
</html>Cet exemple de code est quelque peu simplifié pour la clarté, je ferai de même pour de nombreux autres exemples dans cet article. Le code complet est toujours consultable sur .
Nous avons :
- (
<canvas>), que nous utiliserons pour le rendu du jeu. <link>pour ajouter notre paquet CSS.<script>pour ajouter notre paquet Javascript.- Menu principal avec nom d'utilisateur
<input>et un bouton «JOUER» ().
Après le chargement de la page d'accueil dans le navigateur, le code Javascript commencera à s'exécuter, à partir du fichier JS 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 = () => {
// Jouer !
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});Cela peut sembler compliqué, mais en réalité, il ne se passe pas tant de choses :
- Importation de plusieurs autres fichiers JS.
- Import CSS (pour que Webpack sache qu'il doit les inclure dans notre paquet CSS).
- Lancement
connect()pour établir une connexion avec le serveur et démarrerdownloadAssets()pour télécharger les images nécessaires au rendu du jeu. - Après avoir terminé l'étape 3 le menu principal est affiché (
playMenu). - Configuration du gestionnaire d'événements pour le bouton «JOUER». Lorsqu'on clique sur le bouton, le code initialise le jeu et informe le serveur que nous sommes prêts à jouer.
Le cœur de notre logique client-serveur se trouve dans les fichiers importés par le fichier index.js. Examinons-les tous un par un.
4. Échange de données client
Dans ce jeu, nous utilisons une bibliothèque bien connue pour communiquer avec le serveur. . Socket.io a un support intégral intégré , qui est bien adapté à la communication bidirectionnelle : nous pouvons envoyer des messages au serveur et le serveur peut nous envoyer des messages sur la même connexion.
Nous aurons un fichier src/client/networking.js, qui s'occupera de 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('Connecté au serveur !');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
\/\/ Enregistrer les 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 aussi été légèrement abrégé pour la clarté.
Ce fichier effectue trois actions principales :
- Nous essayons de nous connecter au serveur.
connectedPromiseest résolu uniquement lorsque nous avons établi la connexion. - Si la connexion est réussie, nous enregistrons les fonctions de rappel (
processGameUpdate()etonGameOver()) pour les messages que nous pouvons recevoir du serveur. - Nous exportons
play()etupdateDirection(), afin qu'ils puissent être utilisés par d'autres fichiers.
5. Rendu du client
Il est temps d'afficher l'image à l'écran !
… mais avant de pouvoir le faire, nous devons télécharger toutes les images (ressources) nécessaires. É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(`Téléchargé ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `\/assets\/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; Gérer les ressources n'est pas si compliqué ! 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 téléchargée, nous la sauvegardons dans l'objet assets pour un accès rapide à l'avenir. Une fois que le téléchargement de chaque ressource individuelle (c'est-à-dire que les ressources auront été chargées) tout est autorisé, nous résolvons downloadPromise.
Après avoir téléchargé les ressources, nous pouvons procéder au rendu. Comme mentionné précédemment, pour dessiner sur la page web, nous utilisons (<canvas>). Notre jeu est assez simple, donc il suffit de rendre ce qui suit :
- Fond
- Navire du joueur
- Autres joueurs dans le jeu
- Munitions
Voici des fragments importants src/client/render.js, qui dessinent exactement les quatre points mentionné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;
// Obtenir le contexte graphique du canvas
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Rendre le canvas plein écran
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Dessiner l'arrière-plan
renderBackground(me.x, me.y);
// Dessiner toutes les munitions
bullets.forEach(renderBullet.bind(null, me));
// Dessiner tous les joueurs
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Fonctions d'aide ici exclues
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Ce code est également abrégé pour la clarté.
render() est la fonction principale de ce fichier. startRendering() et stopRendering() gèrent l'activation du cycle de rendu à une fréquence de 60 FPS.
Les implémentations spécifiques des différentes fonctions d'aide au rendu (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,
);
} Remarquez que nous utilisons la méthode getAsset(), que nous avons vue précédemment dans asset.js!
Si vous êtes intéressé à explorer d'autres fonctions d'aide au rendu, lisez le reste de .
6. Entrée du client
Il est temps de rendre le jeu jouable! Le schéma de contrôle sera très simple : pour changer la direction du mouvement, on peut utiliser la souris (sur ordinateur) ou toucher l'écran (sur mobile). Pour réaliser cela, nous allons enregistrer pour les événements Souris et Toucher.
Tout cela sera géré par src/client/input.js:
input.js
import { updateDirection } from './networking';
function onMouseInput(e) {
handleInput(e.clientX, e.clientY);
}
function onTouchInput(e) {
const touch = e.touches[0];
handleInput(touch.clientX, touch.clientY);
}
function handleInput(x, y) {
const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
updateDirection(dir);
}
export function startCapturingInput() {
window.addEventListener('mousemove', onMouseInput);
window.addEventListener('touchmove', onTouchInput);
}
export function stopCapturingInput() {
window.removeEventListener('mousemove', onMouseInput);
window.removeEventListener('touchmove', onTouchInput);
} onMouseInput() et onTouchInput() sont des Event Listeners qui appellent updateDirection() (depuis networking.js) lors d'un événement d'entrée (par exemple, lors du déplacement de la souris). updateDirection() s'occupe de l'échange de messages avec le serveur qui traite l'événement d'entrée et met à jour l'état du jeu en conséquence.
7. État du client
Cette section est la plus complexe de la première partie de l'article. Ne vous découragez pas si vous ne la comprenez pas du premier coup ! Vous pouvez même la sauter et y revenir plus tard.
Le dernier morceau du puzzle nécessaire pour compléter le code client-serveur est state. N'oubliez pas le morceau de code de la section « Rendu du client » ?
render.js
import { getCurrentState } from '. /state';
function render() {
const { me, others, bullets } = getCurrentState();
// Effectuer le rendu
// ...
} getCurrentState() doit être capable de nous fournir l'état actuel du jeu sur le client à tout moment basé sur les 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": "Joueur",
"score": 3
}
]
}Chaque mise à jour de jeu contient cinq champs identiques :
- t: un horodatage du serveur, indiquant le moment où cette mise à jour a été créée.
- me: les informations concernant le joueur recevant cette mise à jour.
- others: un tableau d'informations sur d'autres joueurs participant au même jeu.
- bullets: un tableau d'informations sur les projectiles dans le jeu.
- leaderboard: les données actuelles du tableau des leaders. Dans cet article, nous ne les prendrons pas en compte.
7.1 État naïf du client
Implémentation naïve getCurrentState() ne peut que retourner directement les données de la dernière mise à jour de jeu reçue.
naive-state.js
let lastGameUpdate = null;
// Gérer une mise à jour de jeu nouvellement reçue.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}C'est beau et clair ! Mais si tout était si simple. L'une des raisons pour lesquelles une telle mise en œuvre est problématique : elle limite la fréquence d'images de rendu à la fréquence d'horloge du serveur.
Fréquence d'images (Frame Rate): le nombre d'images (c'est-à-dire d'appels
render()) par seconde, ou FPS. Dans les jeux, on vise généralement à atteindre au moins 60 FPS.
Fréquence d'horloge (Tick Rate): la fréquence à laquelle le serveur envoie des mises à jour de jeu aux clients. Elle est souvent inférieure à la fréquence d'images. Dans notre jeu, le serveur fonctionne à une fréquence de 30 ticks par seconde.
Si nous ne rendons que la dernière mise à jour du jeu, alors le FPS ne pourra pratiquement 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, alors la moitié de ces appels ne fera que redessiner la même chose, ne faisant en réalité rien. Un autre problème de l'implémentation naïve est qu'elle est sujette aux retards. Avec une connexion Internet parfaite, le client recevra la mise à jour du jeu toutes les 33 ms (30 par seconde) :

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

L'implémentation naïve est pratiquement le pire des cas en matière de latence. Si la mise à jour du jeu est reçue avec un retard de 50 ms, alors le client devient lent de 50 ms supplémentaires, car il continue de rendre l'état du jeu de la mise à jour précédente. Vous pouvez imaginer à quel point cela est pénible pour le joueur : à cause des ralentissements aléatoires, le jeu apparaîtra saccadé et instable.
7.2 État amélioré du client
Nous allons apporter quelques améliorations à l'implémentation naïve. Tout d'abord, nous allons utiliser un délai de rendu de 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 le temps sur le serveur est 150, alors l'état qui sera rendu sur le client sera celui dans lequel se trouvait le serveur au moment de 50:

Cela nous donne une marge de 100 ms, permettant de faire face aux temps de réception imprévisibles des mises à jour du jeu :

Le prix à payer pour cela sera un de 100 ms. C'est un sacrifice minime pour un processus de jeu fluide — la plupart des joueurs (surtout les 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 qu'à un retard imprévisible.
Nous pouvons utiliser une autre technique appelée , qui fonctionne bien pour réduire les latences perçues, mais qui ne sera pas abordée dans ce post.
Une autre amélioration que nous utilisons est la interpolation linéaire. En raison du délai de rendu, nous sommes généralement en avance d'au moins une mise à jour sur le temps actuel dans le client. Lorsque est appelé getCurrentState(), nous pouvons procéder à entre les mises à jour du jeu juste avant et après le temps actuel dans le client :

Cela résout le problème de fréquence d'images : nous pouvons maintenant rendre des images uniques à la fréquence souhaitée !
7.3 Implémentation de l'état amélioré du client
Exemple d'implémentation dans src/client/state.js utilise à la fois le retard de rendu et l'interpolation linéaire, mais cela ne durera pas. Découpons le code en deux parties. Voici la première :
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);
// Garder uniquement une mise à jour de jeu avant le temps serveur actuel
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Retourne l'index de la mise à jour de base, la première mise à jour de jeu avant
// le temps serveur actuel, ou -1 si 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;
} Tout d'abord, il faut comprendre ce que fait currentServerTime(). Comme nous l'avons vu précédemment, chaque mise à jour de jeu inclut un horodatage serveur. Nous voulons utiliser le retard de rendu pour afficher l'image avec un décalage de 100 ms par rapport au serveur, mais nous ne saurons jamais quel est le temps actuel sur le serveur, car nous ne pouvons pas savoir combien de temps il a fallu pour recevoir chacune des mises à jour. Internet est imprévisible et sa vitesse peut varier considérablement !
Pour contourner ce problème, nous pouvons utiliser une approximation raisonnable : nous faisons comme si la première mise à jour était arrivée instantanément. Si cela était vrai, nous saurions alors l'heure du serveur à ce moment précis ! Nous conservons l'horodatage du serveur dans firstServerTimestamp et nous sauvegardons notre horodatage (client) à ce même moment dans gameStart.
Oh, attendez une minute. Ne devrait-il pas être temps serveur = temps 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 des horodatages différents sur le client et le serveur et cela dépend de facteurs locaux à ces machines. Ne supposez jamais que les horodatages seront identiques sur toutes les machines.
Maintenant, nous comprenons ce que fait currentServerTime(): il renvoie l'horodatage serveur du temps de rendu actuel.. En d'autres termes, c'est l'heure actuelle du serveur (firstServerTimestamp < + (Date.now() - gameStart)) moins le délai de rendu (RENDER_DELAY).
Maintenant, voyons comment nous traitons les mises à jour du jeu. Lorsqu'une mise à jour est reçue du serveur, nous appelons processGameUpdate(), et nous sauvegardons la nouvelle mise à jour dans le tableau gameUpdates. Ensuite, pour vérifier l'utilisation de la mémoire, nous supprimons toutes les anciennes mises à jour jusqu'à la mise à jour de base, car elles ne sont plus nécessaires.
Qu'est-ce que la « mise à jour de base » ? C'est la première mise à jour que nous trouvons en nous déplaçant vers l'arrière à partir de l'heure actuelle du serveur. Vous vous rappelez de ce schéma ?

La mise à jour du jeu juste à gauche de « Client Render Time » est la mise à jour de base.
À quoi sert la mise à jour de base ? Pourquoi pouvons-nous ignorer les mises à jour jusqu'à la mise à jour de base ? Pour comprendre cela, voyons enfin la mise en œuvre state.js, partie 2 getCurrentState():
Nous traitons trois cas :
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),
};
}
}base < 0
signifie qu'il n'y a pas de mises à jour jusqu'à l'heure actuelle de rendu (voir ci-dessus l'implémentation degetBaseUpdate()). Cela peut se produire au tout début du jeu en raison d'un délai de rendu. Dans ce cas, nous utilisons la dernière mise à jour reçue.baseest la dernière mise à jour que nous avons. Cela peut se produire en raison d'un délai réseau ou d'une mauvaise connexion Internet. Dans ce cas, nous utilisons également la dernière mise à jour que nous avons.Nous avons des mises à jour avant et après l'heure actuelle du rendu, donc nous pouvons- interpoler Tout ce qui reste dans!
est la mise en œuvre de l'interpolation linéaire, qui représente une mathématique simple (mais ennuyeuse). Si vous souhaitez l'étudier par vous-même, ouvrez state.js Partie 2. Serveur backend state.js sur .
Dans cette partie, nous allons examiner le backend Node.js qui gère notre
exemple de jeu .io .
Pour gérer le serveur web, nous allons utiliser un framework web populaire pour Node.js appelé
. Sa configuration sera effectuée par notre fichier point d'entrée du serveur server.js, partie 1 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');
// Configuration d'un serveur Express
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Configuration de Webpack pour le développement
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Serve statique le dossier dist/ en production
app.use(express.static('dist'));
}
// Écoute sur le port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Serveur à l'écoute sur le port ${port}`);Souvenez-vous que dans la première partie, nous avons discuté de Webpack ? C'est ici que nous allons utiliser nos configurations Webpack. Nous allons les appliquer de deux manières :
- Utiliser pour la recompilation automatique de nos paquets de développement, ou
- Servir statiquement le dossier
dist/, dans lequel Webpack écrira nos fichiers après la construction pour la production.
Une autre tâche importante server.js est de configurer le serveur , qui se connecte simplement au serveur Express :
server.js, partie 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Configuration d'Express
// ...
const server = app.listen(port);
console.log(`Serveur à l'écoute sur le port ${port}`);
// Configuration de socket.io
const io = socketio(server);
// Écoutez les connexions socket.io
io.on('connection', socket => {
console.log('Joueur connecté !', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Après l'établissement réussi de la 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 en déléguant à un objet singleton. game:
server.js, partie 3
const Game = require('./game');
// ...
// Configuration du jeu
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 de type .io, donc nous n'aurons besoin que d'une seule instance de Game ('Game') – tous les joueurs jouent sur la même arène ! Dans la prochaine section, nous verrons comment fonctionne cette classe. Game.
2. Le serveur Game
Classe Game contient la logique la plus importante côté serveur. Il a deux tâches principales : la gestion des joueurs et la simulation du 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;
// Générer une position pour démarrer ce joueur.
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 le champ id de leur socket socket.io (si vous êtes perdu, revenez à server.js). Socket.io attribue à chaque socket un identifiant unique, id, donc nous n'avons pas à nous en soucier. Je vais l'appeler ID du joueur.
Gardant cela à l'esprit, examinons les variables d'instance dans la classe Game:
sockets— c'est un objet qui relie l'ID du joueur au socket associé au joueur. Il nous permet d'accéder aux sockets en temps constant par leurs IDs de joueur.players— c'est un objet qui associe l'ID du joueur à l'objet code>Player
bullets — c'est un tableau d'objets Bullet, sans ordre défini.
lastUpdateTime — c'est un horodatage du dernier moment de mise à jour du jeu. Nous verrons bientôt comment il est utilisé.
shouldSendUpdate — c'est une variable auxiliaire. Nous verrons également bientôt son utilisation.
Méthodes addPlayer(), removePlayer() et handleInput() n'ont pas besoin d'explication, elles sont utilisées dans server.js. Si vous avez besoin d'un rafraîchissement de mémoire, revenez un peu en arrière.
La dernière ligne constructor() démarre la boucle de mise à jour du jeu (à une fréquence de 60 mises à jour/seconde) :
game.js, partie 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ Calculer le temps écoulé
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Mettre à jour chaque balle
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Détruire cette balle
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Mettre à jour chaque joueur
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ Appliquer les collisions, donner des points aux joueurs qui touchent des balles
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),
);
\/\/ Vérifier si des joueurs sont morts
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp {
const socket = this.sockets[playerID];
const player = this.players[playerID];
socket.emit(
Constants.MSG_TYPES.GAME_UPDATE,
this.createUpdate(player, leaderboard),
);
});
this.shouldSendUpdate = false;
} else {
this.shouldSendUpdate = true;
}
}
\/\/ ...
} Méthode update() contient probablement la partie la plus importante de la logique côté serveur. Énumérons par ordre tout ce qu'il fait :
- Calcule combien de temps
dts'est écoulé depuis le dernierupdate(). - Met à jour chaque projectile et les détruit si nécessaire. Nous verrons plus tard la mise en œuvre de cette fonctionnalité. Pour l'instant, nous nous contentons de savoir que
bullet.update()retournetrue, si le projectile doit être détruit (il a quitté les limites de l'arène). - Met à jour chaque joueur et crée un projectile si nécessaire. Nous verrons également cette mise en œuvre plus tard —
player.update()peut retourner un objetBullet. - Vérifie les collisions entre les projectiles et les joueurs à l'aide de
applyCollisions(), qui renvoie un tableau de projectiles ayant touché les joueurs. Pour chaque projectile retourné, nous augmentons les points du joueur qui l'a tiré (à l'aide deplayer.onDealtDamage()), puis supprimons le projectile du tableaubullets. - Informe et détruit tous les joueurs tués.
- Envoie à tous les joueurs une mise à jour du jeu à chaque seconde une fois lors de l'appel
update(). Cela nous aide à suivre la variable auxiliaire mentionnée ci-dessusshouldSendUpdate. Puisqueupdate()appelé 60 fois par seconde, nous envoyons des mises à jour de jeu 30 fois par seconde. Ainsi, la fréquence d'horloge du serveur est de 30 horloges par seconde (nous avons parlé de la fréquence d'horloge dans la première partie).
Pourquoi envoyer des mises à jour de jeu seulement une fois sur deux ? Pour économiser de la bande passante. 30 mises à jour de jeu par seconde, c'est très beaucoup !
Pourquoi ne pas simplement appeler
update()30 fois par seconde ? Pour améliorer la simulation du jeu. Plus l'appel est fréquent,update()plus la simulation du jeu sera précise. Mais il ne faut pas trop se laisser emporter par le nombre d'appels,update()car c'est une tâche coûteuse en calcul — 60 par seconde est tout à fait suffisant.
Le reste de la classe Game est composé de méthodes utilitaires 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) b.distanceTo(player) p.serializeForUpdate()),
bullets: nearbyBullets.map(b => b.serializeForUpdate()),
leaderboard,
};
}
} getLeaderboard() est assez simple – il classe les joueurs par score, prend les cinq meilleurs et retourne pour chacun le nom d'utilisateur et le score.
createUpdate() est utilisé dans update() pour créer des mises à jour de jeu qui sont transmises aux joueurs. Son rôle principal est d'appeler les méthodes serializeForUpdate(), implémentées pour les classes Player et Bullet. Notez qu'il ne transmet les données qu'aux joueurs concernant les joueurs et projectiles proches – il n'est pas nécessaire de transmettre des informations sur les objets de jeu éloignés !
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 mobiles circulaires abstraits. Pour profiter de cette similarité entre joueurs et projectiles, commençons par implémenter 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é ici. Cette classe sera un bon point de départ pour l'extension. Voyons comment la classe Bullet utilise 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;
}
// Renvoie vrai si la balle doit être détruite
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Mise en œuvre Bullet est très court ! Nous avons ajouté à Object seulement les extensions suivantes :
- Utilisation du paquet pour la génération aléatoire
idde projectiles. - Ajout du champ
parentID, afin de pouvoir suivre le joueur qui a créé ce projectile. - Ajout d'une valeur de retour à
update(), qui est égal àtrue, si le projectile est hors des limites de l'arène (rappelez-vous, nous en avons parlé dans la section précédente ?).
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;
}
// Renvoie une nouvelle balle créée, ou null.
update(dt) {
super.update(dt);
// Met à jour le score
this.score += dt * Constants.SCORE_PER_SECOND;
// Assure que le joueur reste dans les limites
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
// Tire une balle, si nécessaire
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 cette classe doit contenir encore quelques champs supplémentaires. Son méthode update() effectue un plus grand nombre de tâches, notamment, renvoie le projectile nouvellement créé, si le fireCooldown est écoulé (rappelez-vous, nous en avons discuté 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 Object est une étape importante pour éviter la répétition de code. Par exemple, sans cette classe, chaque objet de jeu aurait une implémentation identique de Object distanceTo() , et synchroniser le copier-coller de toutes ces implémentations dans plusieurs fichiers serait un cauchemar.Cela devient particulièrement important pour les projets de grande envergure , lorsque le nombre de classes d'extension augmente.4. Détection des collisions Object 4. Détection des collisions
4. Reconnaissance des collisions
La seule chose qu'il nous reste à faire est de reconnaître quand les projectiles Touchent les joueurs ! Rappelez-vous ce fragment de code dans la méthode update() dans la classe Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Appliquer les collisions, donner des points aux joueurs pour avoir touché des balles
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 la méthode applyCollisions(), qui retourne tous les projectiles ayant touché les joueurs. Heureusement, ce n'est pas si difficile à faire, car
- Tous les objets en collision sont des cercles et c'est la forme la plus simple pour réaliser la détection des collisions.
- Nous avons déjà une méthode
, et synchroniser le copier-coller de toutes ces implémentations dans plusieurs fichiers serait un cauchemar., que nous avons implémentée dans la classe précédente.Object.
Voici à quoi ressemble notre implémentation de la détection 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 détection simple des collisions est basée sur le fait que deux cercles se percutent si la distance entre leurs centres est inférieure à la somme de leurs rayons.Voici un cas où la distance entre les centres de deux cercles est exactement égale à la somme de leurs rayons :

Il convient de prêter une attention particulière à quelques aspects :
- Un projectile ne doit pas toucher le joueur qui l'a créé. Cela peut être réalisé en comparant
bullet.parentIDavecplayer.id. - Un projectile doit toucher uniquement une fois dans le cas limite d'une collision simultanée avec plusieurs joueurs. Nous allons résoudre ce problème avec l'opérateur
breakdès qu'un joueur touché par le projectile est trouvé, nous arrêtons la recherche et passons au projectile suivant.
Fin
Et voilà ! Nous avons couvert tout ce qu'il faut savoir pour créer un jeu Web du genre .io. Quelle est la prochaine étape ? Créez votre propre jeu .io !
Tout le code de l'exemple est open source et disponible sur .
Source : habr.com
