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:
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 = () => {
Cela peut sembler compliqué, mais en réalité, peu d'actions se produisent ici:
- Importez plusieurs autres fichiers JS.
- Importez du CSS (afin que Webpack sache les inclure dans notre package CSS).
- 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. - Une fois l'étape 3 terminée , le menu principal (
playMenu
) playMenu
. - 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(() => {
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:
- Contexte
- Navire joueur
- Autres joueurs du jeu
- 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;
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();
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;
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);
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();
Nous traitons trois cas:base < 0
signifie 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.base
Ceci 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.- Nous avons une mise à jour avant et après le temps de rendu actuel, vous pouvez donc interpoler !
Il ne reste plus qu'à state.js
implémenter l'interpolation linéaire, qui est un calcul simple (mais ennuyeux). Si vous voulez l'apprendre vous-même, ouvrez-le state.js
sur 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');
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.js
consiste à 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');
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');
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 Game
contient 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;
Dans ce jeu, nous allons identifier les joueurs par le champ de id
leur 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
:sockets
Est 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
bullets
Est un tableau d'objets Bullet
qui 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é.shouldSendUpdate
Est 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 {
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:- Calcule le temps
dt
écoulé depuis le dernier update()
. - . . ,
bullet.update()
true
, ( ). - . —
player.update()
Bullet
. applyCollisions()
, , . , ( player.onDealtDamage()
), bullets
.- .
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 Game
compose de méthodes d'assistance utilisées dans update()
:game.js, partie 3
class Game {
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 Player
et 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 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; }
L'implémentation est Bullet
très courte! Nous avons ajouté Object
uniquement aux extensions suivantes:- Utiliser un package shortid pour générer aléatoirement un
id
projectile. - Ajout d'un champ
parentID
pour suivre le joueur qui a créé ce projectile. - Ajouter une valeur de retour à
update()
, qui est égale true
si 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; }
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 Object
est une étape importante pour éviter la répétabilité du code . Par exemple, sans classe, Object
chaque 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 Object
classes 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 {
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');
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.parentID
avec 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 .