
Uscita nel 2015 è diventata l'antesignana di un nuovo genere , la cui popolarità è notevolmente aumentata da allora. Ho sperimentato in prima persona la crescita della popolarità dei giochi .io: negli ultimi tre anni ho .
Nel caso in cui non abbiate mai sentito parlare di questi giochi: si tratta di giochi web multiplayer gratuiti, a cui è facile partecipare (non è necessaria un'account). Di solito, raggruppano un gran numero di giocatori in un'unica arena. Altri famosi giochi del genere .io: e .
In questo post parleremo di come creare un gioco .io da zero. Se ne ha bisogno, basta conoscere JavaScript: è importante comprendere cose come la sintassi , la parola chiave this e . Anche se non si conosce JavaScript a menadito, si potrà comunque capire la maggior parte del post.
Esempio di gioco .io
Per aiutare nell'apprendimento faremo riferimento a . Prova a giocarci!

Il gioco è abbastanza semplice: controlli una nave nell'arena, dove ci sono altri giocatori. La tua nave spara automaticamente proiettili e cerchi di colpire gli altri giocatori, mentre eviti i loro proiettili.
1. Panoramica / Struttura del progetto
Ti consiglio di un esempio di gioco, in modo da poter seguire il mio esempio.
Nell'esempio vengono utilizzati i seguenti:
- è il framework web più popolare per Node.js, che gestisce il server del gioco.
- è una libreria websocket per lo scambio di dati tra browser e server.
- è un gestore di moduli. Puoi leggere perché utilizzare Webpack .
Ecco come appare la struttura delle cartelle del progetto:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublic/
Tutto nella cartella public/ sarà trasmesso staticamente dal server. In public/assets/ si trovano le immagini utilizzate dal nostro progetto.
src/
Tutto il codice sorgente si trova nella cartella src/. Nominal client/ e server/ parlano da sé, e shared/ contiene un file di costanti, importato sia dal client che dal server.
2. Assemblaggi / Opzioni del progetto
Come detto sopra, per costruire il progetto utilizziamo un gestore di moduli . Diamo un'occhiata alla nostra configurazione di 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',
}),
],
};Le seguenti righe sono le più importanti:
src/client/index.js— questo è il punto di ingresso del client Javascript (JS). Webpack inizierà da qui e cercherà in modo ricorsivo altri file importati.- Il JS di output della nostra build Webpack sarà situato nella directory
dist/. Lo chiamerò il nostro pacchetto JS. - Lo usiamo , e in particolare la configurazione per la transpiling (trasformazione) del nostro codice JS per i browser più vecchi.
- Utilizziamo un plugin per estrarre tutto il CSS a cui fanno riferimento i file JS e per unirli in un unico posto. Lo chiamerò il nostro pacchetto CSS.
Avrete notato nomi di file di pacchetti piuttosto strani '[name].[contenthash].ext'. Contengono Webpack: [name] sarà sostituito con il nome del punto di ingresso (in questo caso è game), e [contenthash] sarà sostituito con l'hash del contenuto del file. Lo facciamo per — possiamo ordinare ai browser di memorizzare in cache i nostri pacchetti JS all'infinito, perché se un pacchetto viene modificato, cambia anche il nome del file (cambia il contenthash). Il risultato finale sarà un nome di file del tipo game.dbeee76e91a97d0c7207.js.
all'interno di ogni container per impostazione predefinita apparirà così: webpack.common.js — è il file di configurazione di base che importiamo nelle configurazioni di sviluppo e del progetto pronto. Ecco, ad esempio, la configurazione di sviluppo:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); Per efficienza utilizziamo nel processo di sviluppo webpack.dev.js, e cambia a webpack.prod.js, per ottimizzare le dimensioni dei pacchetti durante il deployment in produzione.
Impostazione locale
Consiglio di impostare il progetto sulla macchina locale, in modo da poter seguire i passaggi elencati in questo post. La configurazione è semplice: prima di tutto, devono essere installati nel sistema e . Poi bisogna eseguire
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installe siete pronti a lavorare! Per avviare il server di sviluppo è sufficiente eseguire
$ npm run develope accedere al browser web su . Il server di sviluppo ricomporrà automaticamente i pacchetti JS e CSS mentre il codice viene modificato: basta aggiornare la pagina per vedere tutte le modifiche!
3. Punti di ingresso del client
Iniziamo a scrivere il codice del gioco. Per cominciare, avremo bisogno di una pagina index.html, che verrà caricata per prima quando il sito viene visitato. La nostra pagina sarà abbastanza semplice:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Un esempio di gioco .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="Nome utente" />
<button id="play-button">GIOCA</button>
</div>
</body>
</html>Questo esempio di codice è stato leggermente semplificato per chiarezza; farò lo stesso con molti altri esempi nel post. Il codice completo è sempre consultabile su .
Abbiamo:
- (
<canvas>), che useremo per il rendering del gioco. <link>per aggiungere il nostro pacchetto CSS.<script>per aggiungere il nostro pacchetto Javascript.- Menu principale con nome utente
<input>e un pulsante «PLAY» ().
Una volta caricata la pagina principale nel browser, verrà eseguito il codice Javascript, a partire dal file JS del punto di ingresso: 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 = () => {
// Gioca!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});Questo potrebbe sembrare complicato, ma in realtà non ci sono molte azioni coinvolte:
- Importazione di diversi altri file JS.
- Importazione di CSS (in modo che Webpack sappia di includerli nel nostro pacchetto CSS).
- Avvio
connect()per stabilire una connessione con il server e avviaredownloadAssets()per scaricare le immagini necessarie per il rendering del gioco. - Al termine della fase 3 viene visualizzato il menu principale (
playMenu). - Impostazione del gestore di eventi per il pulsante «PLAY». Quando si preme il pulsante, il codice inizializza il gioco e avvisa il server che siamo pronti a giocare.
Il "nocciolo" della nostra logica client-server si trova nei file importati dal file index.js. Ora li esamineremo uno per uno.
4. Scambio di dati del client
In questo gioco, per comunicare con il server utilizziamo una libreria ben nota . In Socket.io c'è un supporto integrato , che si adatta bene alla comunicazione bidirezionale: possiamo inviare messaggi al server e il server può inviarci messaggi sulla stessa connessione.
Avremo un file src/client/networking.js, che si occuperà di tutte le comunicazioni con il server:
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('Connesso al server!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
// Registra i callback
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);
};Questo codice è anche leggermente abbreviato per semplicità.
In questo file si svolgono tre attività principali:
- Proviamo a connetterci al server.
connectedPromisesi risolve solo quando abbiamo stabilito la connessione. - Se la connessione viene stabilita con successo, registriamo le funzioni di callback (
processGameUpdate()eonGameOver()) per i messaggi che possiamo ricevere dal server. - Esportiamo
play()eupdateDirection(), affinché possano essere utilizzati da altri file.
5. Rendering del client
È tempo di visualizzare un'immagine sullo schermo!
…ma prima di poter fare ciò, dobbiamo scaricare tutte le immagini (risorse) necessarie. Scriviamo un gestore delle risorse:
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(`Scaricato ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `/assets/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; Gestire le risorse non è così difficile! Il concetto principale è memorizzare un oggetto assets, che collegherà la chiave del nome del file al valore dell'oggetto Immagine. Quando la risorsa verrà caricata, la salveremo nell'oggetto assets per un accesso rapido in futuro. Quando il download di ciascuna risorsa viene completato (ovvero le tutti risorse verranno caricate), risolviamo downloadPromise.
Dopo aver scaricato le risorse, possiamo procedere con il rendering. Come già detto, per disegnare sulla pagina web utilizziamo (<canvas>). Il nostro gioco è piuttosto semplice, quindi ci basta disegnare solo quanto segue:
- Sfondo
- La nave del giocatore
- Altri giocatori presenti nel gioco
- Proiettili
Ecco i frammenti importanti src/client/render.js, che disegnano esattamente i quattro punti sopra menzionati:
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;
// Ottieni il contesto grafico del canvas
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Rendi il canvas a schermo intero
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Disegna lo sfondo
renderBackground(me.x, me.y);
// Disegna tutti i proiettili
bullets.forEach(renderBullet.bind(null, me));
// Disegna tutti i giocatori
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Funzioni di supporto qui escluse
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Questo codice è stato anche abbreviato per chiarezza.
render() è la funzione principale di questo file. startRendering() e stopRendering() gestiscono l'attivazione del ciclo di rendering a 60 FPS.
Le implementazioni specifiche delle singole funzioni di supporto per il rendering (ad esempio renderBullet()) non sono così importanti, ma ecco un semplice esempio:
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,
);
} Nota che utilizziamo il metodo getAsset(), che abbiamo visto in precedenza in asset.js!
Se sei interessato a esplorare altre funzioni di supporto per il rendering, leggi il resto .
6. Input del client
È tempo di rendere il gioco giocabile! Il sistema di controllo sarà molto semplice: per cambiare la direzione del movimento, puoi utilizzare il mouse (sul computer) o toccare lo schermo (su dispositivo mobile). Per realizzarlo, registreremo per eventi Mouse e Touch.
Tutto ciò sarà gestito da 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() e onTouchInput() sono i più Event Listeners che attivano updateDirection() (da networking.js) quando si verifica un evento di input (ad esempio, quando si muove il mouse). updateDirection() gestisce lo scambio di messaggi con il server, che elabora l'evento di input e aggiorna lo stato del gioco di conseguenza.
7. Stato del client
Questa sezione è la più complessa della prima parte del post. Non ti preoccupare se non la capisci al primo tentativo! Puoi anche saltarla e tornarci più tardi.
L'ultimo pezzo del puzzle necessario per completare il codice client-server è state. Ricordi il frammento di codice nella sezione "Rendering del client"?
render.js
import { getCurrentState } from '.\/state';
function render() {
const { me, others, bullets } = getCurrentState();
\/\/ Esegui il rendering
\/\/ ...
} getCurrentState() deve essere in grado di fornirci lo stato attuale del gioco sul client in qualsiasi momento sulla base degli aggiornamenti ricevuti dal server. Ecco un esempio di aggiornamento del gioco che il server potrebbe inviare:
{
"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
}
]
}Ogni aggiornamento del gioco contiene cinque campi identici:
- t: un timestamp del server che indica il momento della creazione di questo aggiornamento.
- me: informazioni sul giocatore che riceve questo aggiornamento.
- others: un array di informazioni sugli altri giocatori coinvolti nello stesso gioco.
- bullets: un array di informazioni sui proiettili nel gioco.
- leaderboard: dati attuali della classifica. In questo post non li prenderemo in considerazione.
7.1 Stato naive del client
Implementazione naive getCurrentState() può semplicemente restituire i dati dell'ultimo aggiornamento del gioco ricevuto.
naive-state.js
let lastGameUpdate = null;
// Gestisci un nuovo aggiornamento del gioco ricevuto.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Bello e chiaro! Ma se solo fosse così semplice. Una delle ragioni per cui tale implementazione è problematica è che limita il frame rate del rendering alla frequenza di aggiornamento del server.
Frame Rate: il numero di fotogrammi (cioè chiamate
render()) al secondo, o FPS. Nei giochi si cerca di raggiungere almeno 60 FPS.
Tick Rate: la frequenza con cui il server invia aggiornamenti del gioco ai client. Spesso è inferiore al frame rate. Nel nostro gioco, il server opera a una frequenza di 30 tick al secondo.
Se ci limiteremo a rendere l'ultimo aggiornamento del gioco, gli FPS non potranno mai superare 30, perché non riceviamo mai più di 30 aggiornamenti al secondo dal server. Anche se chiamiamo render() 60 volte al secondo, metà di queste chiamate semplicemente ridisegnerà la stessa cosa, sostanzialmente senza fare nulla. Un altro problema di un'implementazione naif è che è soggetta a latenze. Con una velocità di Internet ideale, il client riceverà un aggiornamento del gioco esattamente ogni 33 ms (30 al secondo):

Purtroppo, nulla è perfetto. Un'immagine più realistica sarebbe questa:

L'implementazione naif è praticamente il peggior caso in termini di latenze. Se un aggiornamento del gioco viene ricevuto con un ritardo di 50 ms, allora il client rallenta di ulteriori 50 ms, perché continua a rendere lo stato del gioco dall'aggiornamento precedente. Puoi immaginare quanto sia scomodo per il giocatore: a causa di rallentamenti arbitrari il gioco apparirà scattoso e instabile.
7.2 Stato migliorato del client
Effettueremo alcuni miglioramenti all'implementazione naif. Prima di tutto, utilizzeremo un ritardo di rendering di 100 ms. Ciò significa che lo stato 'corrente' del client sarà sempre in ritardo rispetto allo stato del gioco sul server di 100 ms. Ad esempio, se sul server il tempo è 150, lo stato renderizzato dal client sarà quello in cui si trovava il server al momento di 50:

Questo ci fornisce un buffer di 100 ms, che consente di affrontare il tempo imprevedibile di ricezione degli aggiornamenti del gioco:

Il prezzo da pagare per questo sarà un costante di 100 ms. Questo è un sacrificio insignificante per un'esperienza di gioco fluida: la maggior parte dei giocatori (soprattutto i casual) nemmeno noterà questo ritardo. È molto più facile per le persone adattarsi a un ritardo costante di 100 ms piuttosto che giocare con un ritardo imprevedibile.
Possiamo utilizzare anche un'altra tecnica chiamata , che affronta bene la riduzione delle latenze percepite, ma non verrà considerata in questo post.
Un ulteriore miglioramento che utilizziamo è l'interpolazione lineare. A causa del ritardo di rendering, di solito superiamo lo stato attuale nel client di almeno un aggiornamento. Quando viene chiamato getCurrentState(), possiamo eseguire tra gli aggiornamenti del gioco immediatamente prima e dopo l'orario corrente nel client:

Questo risolve il problema con la frequenza dei fotogrammi: ora possiamo renderizzare fotogrammi unici con qualsiasi frequenza ci serva!
7.3 Implementazione di uno stato client migliorato
Esempio di implementazione in src/client/state.js usa sia il ritardo di rendering che l'interpolazione lineare, ma solo per un breve periodo. Dobbiamo dividere il codice in due parti. Ecco la prima:
state.js, parte 1
const RENDER_DELAY = 100;
const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;
export function initState() {
gameStart = 0;
firstServerTimestamp = 0;
}
export function processGameUpdate(update) {
if (!firstServerTimestamp) {
firstServerTimestamp = update.t;
gameStart = Date.now();
}
gameUpdates.push(update);
// Mantieni solo un aggiornamento di gioco prima dell'orario corrente del server
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Restituisce l'indice dell'aggiornamento base, il primo aggiornamento di gioco prima
// dell'orario corrente del server, o -1 se 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;
} Per prima cosa dobbiamo capire cosa fa currentServerTime(). Come abbiamo visto in precedenza, in ogni aggiornamento del gioco è inclusa la marca temporale del server. Vogliamo utilizzare il ritardo di rendering per renderizzare l'immagine con un ritardo di 100 ms dal server, ma non sapremo mai qual è l'ora attuale sul server, perché non possiamo sapere quanto tempo ha impiegato ciascun aggiornamento per raggiungerci. Internet è imprevedibile e la sua velocità può variare notevolmente!
Per aggirare questo problema, possiamo utilizzare un'adeguata approssimazione: noi fingiamo che il primo aggiornamento sia arrivato istantaneamente. Se fosse vero, sapremmo l'ora del server in quel momento specifico! Salviamo la marca temporale del server in firstServerTimestamp e salviamo la nostra marca temporale locale (client) nel medesimo momento in.
gameStart Oh, aspetta un attimo. Non dovrebbe essere l'ora del server = l'ora nel client? Perché distinguiamo tra "marca temporale del server" e "marca temporale del client"? È una bellissima domanda! Si scopre che non sono la stessa cosa. Date.now() restituirà marche temporali diverse nel client e nel server e ciò dipende da fattori locali per quelle macchine.
Non dare mai per scontato che le marche temporali siano le stesse su tutte le macchine. currentServerTime()Ora ci è chiaro cosa fa : restituisce la marca temporale del server per l'ora corrente di rendering. In altre parole, questo è l'orario attuale del server (firstServerTimestamp < (Date.now() - gameStart)) meno il ritardo di rendering (RENDER_DELAY).
Ora vediamo come gestiamo gli aggiornamenti di gioco. Quando riceviamo un aggiornamento dal server, viene chiamato processGameUpdate(), e salviamo il nuovo aggiornamento nell'array gameUpdates. Poi, per monitorare l'utilizzo della memoria, rimuoviamo tutti gli aggiornamenti obsoleti fino a aggiornamento di base, perché non ci servono più.
Che cos'è un «aggiornamento di base»? È il primo aggiornamento che troviamo muovendoci all'indietro dall'orario attuale del server. Ricordi questo schema?

L'aggiornamento di gioco direttamente a sinistra del «Client Render Time» è l'aggiornamento di base.
A cosa serve l'aggiornamento di base? Perché possiamo scartare aggiornamenti fino al base? Per chiarire questo, vediamo finalmente l'implementazione getCurrentState():
state.js, parte 2
export function getCurrentState() {
if (!firstServerTimestamp) {
return {};
}
const base = getBaseUpdate();
const serverTime = currentServerTime();
// If base is the most recent update we have, use its state.
// Else, interpolate between its state and the state of (base + 1).
if (base < 0) {
return gameUpdates[gameUpdates.length - 1];
} else if (base === gameUpdates.length - 1) {
return gameUpdates[base];
} else {
const baseUpdate = gameUpdates[base];
const next = gameUpdates[base + 1];
const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
return {
me: interpolateObject(baseUpdate.me, next.me, r),
others: interpolateObjectArray(baseUpdate.others, next.others, r),
bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
};
}
}Gestiamo tre casi:
base < 0significa che non ci sono aggiornamenti fino all'orario attuale di rendering (vedi l'implementazione sopra digetBaseUpdate()). Questo può accadere all'inizio del gioco a causa del ritardo di rendering. In tal caso, usiamo l'ultimo aggiornamento ricevuto.base— è l'ultimo aggiornamento che abbiamo. Questo può verificarsi a causa di ritardi di rete o connessione Internet scadente. Anche in questo caso utilizziamo l'ultimo aggiornamento che abbiamo.- Abbiamo aggiornamenti sia prima che dopo l'orario attuale di rendering, quindi possiamo interpolare!
Tutto ciò che resta in state.js è l'implementazione dell'interpolazione lineare, che è una matematica semplice (ma noiosa). Se vuoi esplorarlo da solo, apri state.js in .
Parte 2. Server backend
In questa parte esamineremo il backend Node.js che gestisce il nostro .
1. Punto di ingresso del server
Per gestire il server web useremo un popolare framework web per Node.js chiamato . La sua configurazione sarà gestita dal nostro file di ingresso del server src/server/server.js:
server.js, parte 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');
// Imposta un server Express
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Imposta Webpack per lo sviluppo
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Serve staticamente la cartella dist/ in produzione
app.use(express.static('dist'));
}
// Ascolta sulla porta
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server in ascolto sulla porta ${port}`);Ricorda che nella prima parte abbiamo discusso di Webpack? È proprio qui che utilizzeremo le nostre configurazioni Webpack. Le applicheremo in due modi:
- di utilizzare per la ricompilazione automatica dei nostri pacchetti di sviluppo, oppure
- Servire staticamente la cartella
dist/, in cui Webpack scriverà i nostri file dopo la compilazione per la produzione.
Un'altra importante funzione server.js consiste nell'impostare il server , che si collega semplicemente al server Express:
server.js, parte 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Imposta Express
// ...
const server = app.listen(port);
console.log(`Server in ascolto sulla porta ${port}`);
// Imposta socket.io
const io = socketio(server);
// Ascolta le connessioni socket.io
io.on('connection', socket => {
console.log('Giocatore connesso!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Dopo aver stabilito con successo la connessione socket.io con il server, configuriamo i gestori di eventi per il nuovo socket. I gestori di eventi elaborano i messaggi ricevuti dai clienti delegando all'oggetto singletone game:
server.js, parte 3
const Game = require('./game');
// ...
// Imposta il Gioco
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Stiamo creando un gioco del genere .io, quindi abbiamo bisogno di un solo esemplare Game ('Game') – tutti i giocatori giocano su un'unica arena! Nella sezione successiva vedremo come funziona questa classe Game.
2. Game del server
Classe Game contiene la logica più importante lato server. Ha due principali compiti: gestire i giocatori e simulare il gioco.
Iniziamo con il primo compito – la gestione dei giocatori.
game.js, parte 1
const Constants = require('..\/shared\/constants');
const Player = require('.\/player');
class Game {
constructor() {
this.sockets = {};
this.players = {};
this.bullets = [];
this.lastUpdateTime = Date.now();
this.shouldSendUpdate = false;
setInterval(this.update.bind(this), 1000 \/ 60);
}
addPlayer(socket, username) {
this.sockets[socket.id] = socket;
\/\/ Genera una posizione per far partire questo giocatore.
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);
}
}
\/\/ ...
} In questo gioco identificheremo i giocatori tramite il campo id del loro socket socket.io (se ti senti confuso, torna ancora a server.js). Socket.io assegna automaticamente a ogni socket un idunico ID, quindi non dobbiamo preoccuparcene. Lo chiamerò ID giocatore.
Ricordato questo, esaminiamo le variabili d'istanza nella classe Game:
socketsè un oggetto che associa l'ID del giocatore al socket collegato al giocatore. Ci permette di accedere ai socket in tempo costante attraverso i loro ID giocatori.playersè un oggetto che associa l'ID del giocatore all'oggetto code>Player
bullets è un array di oggetti Bullet, senza un ordine specifico.
lastUpdateTime è un timestamp dell'ultimo aggiornamento del gioco. Tra poco vedremo come viene utilizzato.
shouldSendUpdate è una variabile ausiliaria. Anche il suo utilizzo lo vedremo presto.
Metodi addPlayer(), removePlayer() e handleInput() non necessitano di spiegazioni, vengono utilizzati in server.js. Se hai bisogno di un ripasso, torna un po' indietro.
L'ultima linea constructor() avvia il ciclo di aggiornamento del gioco (con una frequenza di 60 aggiornamenti/s):
game.js, parte 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ Calcola il tempo trascorso
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Aggiorna ogni proiettile
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Distruggi questo proiettile
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Aggiorna ogni giocatore
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ Applica le collisioni, assegna ai giocatori un punteggio per aver colpito i proiettili
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),
);
\/\/ Controlla se ci sono giocatori morti
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;
}
}
\/\/ ...
} Sanitizer.replaceElementWithChildren() update() contiene, probabilmente, la parte più importante della logica sul lato server. Elencheremo in ordine tutto ciò che fa:
- Calcola quanto tempo
dtè passato dall'ultimoupdate(). - Aggiorna ogni proiettile e lo distrugge se necessario. Vedremo l'implementazione di questa funzionalità più avanti. Al momento ci basta sapere che
bullet.update()false, se l'elemento da sostituire ètrue, se il proiettile deve essere distrutto (è uscito dai confini dell'arena). - Aggiorna ogni giocatore e genera un proiettile se necessario. Vedremo anche questa implementazione più avanti —
player.update()può restituire un oggettoBullet. - Controlla le collisioni tra i proiettili e i giocatori utilizzando
applyCollisions(), che restituisce un array di proiettili che hanno colpito i giocatori. Per ogni proiettile restituito aumentiamo il punteggio del giocatore che lo ha lanciato (utilizzandoplayer.onDealtDamage()), e poi rimuoviamo il proiettile dall'arraybullets. - Notifica e distrugge tutti i giocatori uccisi.
- Invia a tutti i giocatori un aggiornamento del gioco ogni secondo volta in cui viene chiamato
update(). Questo ci aiuta a seguire la variabile ausiliaria menzionata soprashouldSendUpdate. Poichéupdate()viene chiamato 60 volte/s, inviamo aggiornamenti di gioco 30 volte/s. In questo modo, frequenza di clock del server è di 30 cicli/s (abbiamo parlato della frequenza di clock nella prima parte).
Perché inviare aggiornamenti di gioco solo ogni due volte ? Per risparmiare banda. 30 aggiornamenti di gioco al secondo sono davvero tanti!
Perché non chiamarlo
update()30 volte al secondo? Per migliorare la simulazione del gioco. Più frequentemente viene chiamatoupdate(), più precisa sarà la simulazione del gioco. Ma non bisogna esagerare con il numero di chiamateupdate(), perché questo è un compito che richiede molto calcolo: 60 al secondo sono più che sufficienti.
La parte rimanente della classe Game è composta da metodi ausiliari utilizzati in update():
game.js, parte 3
class Game {
getLeaderboard() {
return Object.values(this.players)
.sort((p1, p2) => p2.score - p1.score)
.slice(0, 5)
.map(p => ({ username: p.username, score: Math.round(p.score) }));
}
createUpdate(player, leaderboard) {
const nearbyPlayers = Object.values(this.players).filter(
p => p !== player && p.distanceTo(player) b.distanceTo(player) p.serializeForUpdate()),
bullets: nearbyBullets.map(b => b.serializeForUpdate()),
leaderboard,
};
}
} getLeaderboard() è abbastanza semplice: ordina i giocatori in base ai punti, prende i migliori cinque e restituisce per ciascuno il nome utente e il punteggio.
createUpdate() viene utilizzato in update() per creare aggiornamenti di gioco che vengono inviati ai giocatori. Il suo compito principale consiste nel chiamare i metodi serializeForUpdate(), implementati per le classi Player e Bullet. Nota che invia a ciascun giocatore solo informazioni sui giocatori vicini e proiettili: non c'è bisogno di inviare informazioni sugli oggetti di gioco che si trovano lontano dal giocatore!
3. Oggetti di gioco sul server
Nel nostro gioco, proiettili e giocatori sono in realtà molto simili: sono oggetti di gioco astratti, rotondi e mobili. Per sfruttare questa somiglianza tra giocatori e proiettili, iniziamo con l'implementazione della classe di 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,
};
}
} Non c'è niente di complicato qui. Questa classe sarà un buon punto di partenza per l'espansione. Vediamo come la classe Bullet utilizza 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;
}
\/\/ Ritorna true se il proiettile deve essere distrutto
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Implementazione Bullet molto corta! Abbiamo aggiunto a Object solo le seguenti estensioni:
- Utilizzo del pacchetto per la generazione casuale
iddel proiettile. - Aggiunta di un campo
parentID, per tenere traccia del giocatore che ha creato questo proiettile. - Aggiunta di un valore di ritorno a
update(), che è uguale atrue, se il proiettile si trova oltre i confini dell'arena (ricorda, ne abbiamo parlato nella sezione precedente?).
Passiamo a 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;
}
\/\/ Ritorna un nuovo proiettile creato, o null.
update(dt) {
super.update(dt);
\/\/ Aggiorna il punteggio
this.score += dt * Constants.SCORE_PER_SECOND;
\/\/ Assicurati che il giocatore rimanga entro i limiti
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
\/\/ Spara un proiettile, se necessario
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,
};
}
} I giocatori sono più complessi dei proiettili, quindi in questa classe devono essere conservati anche altri campi. Il suo metodo update() esegue un lavoro maggiore, restituendo un proiettile appena creato, se non è rimasto fireCooldown (ricorda, ne abbiamo parlato nella sezione precedente?). Estende anche il metodo serializeForUpdate(), poiché dobbiamo includere nel gioco dati aggiuntivi per il giocatore.
Avere una classe base Object è un passo importante per evitare la ripetizione del codice. Ad esempio, senza la classe Object ogni oggetto di gioco dovrebbe avere la stessa implementazione di distanceTo(), e la sincronizzazione della copia di tutte queste implementazioni in vari file sarebbe un incubo. Ciò diventa particolarmente importante per i progetti di grandi dimensioni, quando il numero di classi che estendono aumenta. Object 4. Riconoscimento delle collisioni
4. Riconoscimento delle collisioni
L'unica cosa che ci resta è riconoscere quando i proiettili colpiscono i giocatori! Ricorda questo frammento di codice dal metodo update() nella classe Game:
game.js
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ ...
\/\/ Applica le collisioni, assegna punteggio ai giocatori per i proiettili colpiti
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),
);
\/\/ ...
}
} Dobbiamo implementare il metodo applyCollisions(), che restituisce tutti i proiettili che colpiscono i giocatori. Fortunatamente, non è così complicato farlo, perché
- Tutti gli oggetti in collisione sono cerchi, e questa è la figura più semplice per implementare il riconoscimento delle collisioni.
- Abbiamo già un metodo
distanceTo(), che abbiamo implementato nella classe nel paragrafo precedente.Object.
Ecco come appare la nostra implementazione del riconoscimento delle collisioni:
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;
}Questo semplice riconoscimento delle collisioni si basa sul fatto che due cerchi collidono se la distanza tra i loro centri è minore della somma dei loro raggi.Ecco un caso in cui la distanza tra i centri di due cerchi è esattamente uguale alla somma dei loro raggi:

Qui dobbiamo prestare attenzione anche a un paio di aspetti:
- Un proiettile non deve colpire il giocatore che lo ha creato. Questo può essere raggiunto confrontando
bullet.parentIDconplayer.id. - Un proiettile deve colpire solo una volta nel caso limite di collisione simultanea con più giocatori. Risolveremo questo problema con l'operatore
break: non appena troviamo un giocatore che è colliso con il proiettile, interrompiamo la ricerca e passiamo al proiettile successivo.
Fine
Ecco fatto! Abbiamo esaminato tutto ciò che è necessario sapere per creare un gioco web del genere .io. E ora? Crea il tuo gioco .io!
Tutto il codice di esempio è open source e pubblicato su .
Fonte: habr.com
