
Uscita nel 2015 è stata la precorritrice di un nuovo genere , la cui popolarità è notevolmente aumentata da allora. Ho sperimentato la crescita della popolarità dei giochi .io in prima persona: negli ultimi tre anni ho .
Nel caso in cui non abbiate mai sentito parlare di questi giochi: si tratta di giochi web multiplayer gratuiti, ai quali è facile partecipare (non è necessario un account). Di solito, mettono contro molti giocatori su un'unica arena. Altri giochi famosi del genere .io: e .
In questo post esploreremo come creare un gioco .io da zero. Per questo vi basterà conoscere Javascript: dovete capire concetti come la sintassi , la parola chiave this e . Anche se non conoscete perfettamente Javascript, sarete comunque in grado di comprendere gran parte del post.
Esempio di gioco .io
Per facilitarvi l'apprendimento faremo riferimento a un . Provate a giocarci!

Il gioco è piuttosto semplice: controllate una nave nell'arena, dove ci sono altri giocatori. La vostra nave spara automaticamente proiettili e cercate di colpire gli altri giocatori, mentre evitate i loro proiettili.
1. Panoramica e struttura del progetto
Consiglio del esempio di gioco, così puoi seguirmi.
L'esempio utilizza quanto segue:
- — il framework web più popolare per Node.js, che gestisce il server web del gioco.
- — una libreria websocket per scambiare dati tra browser e server.
- — un gestore di moduli. Puoi leggere perché utilizzare Webpack .
Ecco come appare la struttura della directory del progetto:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublic/
Tutto nella cartella public/ sarà servito staticamente dal server. In public/assets/ ci sono le immagini utilizzate dal nostro progetto.
src/
Tutto il codice sorgente si trova nella cartella src/. Nomi client/ e server/ parlano da soli, e shared/ contiene un file di costanti, importato sia dal client che dal server.
2. Build / parametri del progetto
Come detto in precedenza, per il build del progetto utilizziamo il 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 righe più importanti qui sono le seguenti:
src/client/index.js— è il punto di ingresso del client Javascript (JS). Webpack inizierà da qui e cercherà ricorsivamente altri file importati.- Il JS di output della nostra build Webpack si troverà nella directory
dist/. Chiamerò questo file il nostro pacchetto JS. - Utilizziamo , e in particolare la configurazione per transpillare il nostro codice JS per i browser più vecchi.
- Utilizziamo un plugin per estrarre tutti i CSS a cui fanno riferimento i file JS e per combinarli in un unico luogo. Lo chiamerò il nostro pacchetto CSS.
Potresti aver notato nomi di file dei pacchetti strani '[name].[contenthash].ext'. Contengono Webpack: [name] verrà sostituito con il nome del punto di ingresso (nel nostro caso è game), e [contenthash] verrà sostituito con l'hash del contenuto del file. Lo facciamo per — possiamo dire ai browser di memorizzare nella cache i nostri pacchetti JS all'infinito, perché se un pacchetto cambia, cambia anche il nome del suo file (cambia contenthash). Il risultato finale sarà un nome di file del tipo game.dbeee76e91a97d0c7207.js.
File webpack.common.js — questo è il file di configurazione di base che importiamo nelle configurazioni di sviluppo e del progetto finale. 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 si passa a webpack.prod.js, per ottimizzare le dimensioni dei pacchetti durante il deployment in produzione.
Impostazione locale
Ti consiglio di installare il progetto sulla macchina locale, così potrai seguire i passaggi elencati in questo post. L'impostazione è semplice: prima di tutto, devono essere installati nel sistema e . Successivamente, bisogna eseguire
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installe sei pronto per iniziare! Per avviare il server di sviluppo, è sufficiente eseguire
$ npm run develope accedere nel browser a . Il server di sviluppo ricompilerà automaticamente i pacchetti JS e CSS durante la modifica del codice: basta aggiornare la pagina per vedere tutte le modifiche!
3. Punti di accesso del client
Iniziamo con il codice del gioco. Prima di tutto, avremo bisogno di una pagina index.html, che il browser caricherà per prima visitando il sito. La nostra pagina sarà piuttosto semplice:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Un gioco di esempio .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 è leggermente semplificato per chiarezza; lo stesso farò con molti altri esempi del post. Il codice completo è sempre visibile su .
Abbiamo:
- (
<canvas>), che utilizzeremo 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 homepage, il codice Javascript inizierà ad essere eseguito, partendo dal file JS 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 = () => {
// Play!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});Può sembrare complicato, ma in realtà qui non ci sono molte azioni in corso:
- Importazione di altri file JS.
- Importazione del 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 al rendering del gioco. - Dopo il completamento della fase 3 viene visualizzato il menu principale (
playMenu). - Impostazione del gestore di eventi per il pulsante 'PLAY'. Quando il pulsante viene premuto, il codice inizializza il gioco e informa il server che siamo pronti a giocare.
La parte fondamentale della nostra logica client-server si trova nei file che sono stati importati dal file index.js. Ora esamineremo tutti in ordine.
4. Scambio di dati del cliente
In questo gioco utilizziamo una biblioteca ben nota per comunicare con il server. . In Socket.io c'è supporto integrato , che è ideale per la comunicazione bidirezionale: possiamo inviare messaggi al server e e il server può inviare messaggi a noi attraverso la 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 è stato anche leggermente abbreviato per chiarezza.
In questo file avvengono tre azioni principali:
- Tentiamo di connetterci al server.
connectedPromiseviene risolto solo quando abbiamo stabilito la connessione. - Se la connessione è stabilita correttamente, 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 l'immagine sullo schermo!
…ma prima di poterlo fare, 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! La cosa principale è mantenere un oggetto assets, che collegherà la chiave del nome del file al valore dell'oggetto Image. Quando la risorsa è caricata, la salviamo nell'oggetto assets per un accesso rapido in futuro. Quando viene consentito il download di ciascuna risorsa (cioè quando le все risorse sono state caricate), autorizziamo downloadPromise.
Una volta scaricati le risorse, puoi iniziare il rendering. Come detto in precedenza, per disegnare sulla pagina web utilizziamo (<canvas>). Il nostro gioco è abbastanza semplice, quindi è sufficiente disegnare solo il seguente:
- Sfondo
- Nave del giocatore
- Altri giocatori presenti nel gioco
- Proiettili
Ecco i frammenti importanti src/client/render.js, che disegnano esattamente i quattro punti sopra elencati:
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 è anche abbreviato per chiarezza.
render() è la funzione principale di questo file. startRendering() e stopRendering() controllano l'attivazione del ciclo di rendering a 60 FPS.
Le implementazioni specifiche di singole funzioni ausiliarie di 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 già visto in asset.js!
Se sei interessato a esplorare altre funzioni ausiliarie di rendering, leggi il resto di .
6. Input del cliente
È tempo di rendere il gioco giocabile! Lo schema di controllo sarà molto semplice: per cambiare direzione puoi usare il mouse (sul computer) o il tocco dello schermo (sul dispositivo mobile). Per realizzarlo, registreremo per gli eventi Mouse e Touch.
Tutto questo 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 Event Listeners che invocano updateDirection() (da networking.js) quando si verifica un evento di input (ad esempio, spostando il mouse). updateDirection() gestisce la comunicazione con il server, che processa l'evento di input e aggiorna di conseguenza lo stato del gioco.
7. Lo stato del client
Questa sezione è la più complessa della prima parte del post. Non preoccuparti 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 della 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 nel client in qualsiasi momento sulla base degli aggiornamenti ricevuti dal server. Ecco un esempio di aggiornamento del gioco che potrebbe inviare il server:
{
"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: timestamp del server, che indica il momento in cui è stato creato questo aggiornamento.
- me: informazioni sul giocatore che riceve questo aggiornamento.
- others: array di informazioni su altri giocatori coinvolti nello stesso gioco.
- bullets: array di informazioni sui proiettili nel gioco.
- leaderboard: dati attuali della classifica. In questo post, non li considereremo.
7.1 Stato naive del client
Implementazione naive getCurrentState() può solo restituire direttamente i dati dell'ultimo aggiornamento del gioco ricevuto.
naive-state.js
let lastGameUpdate = null;
// Gestire un aggiornamento di gioco ricevuto di recente.
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: limita la frequenza dei fotogrammi di rendering alla frequenza dell'orologio del server.
Frequenza dei fotogrammi (Frame Rate): numero di fotogrammi (cioè chiamate
render()) al secondo, o FPS. Nei giochi si cerca normalmente di raggiungere almeno 60 FPS.
Frequenza di tick (Tick Rate): la frequenza con cui il server invia aggiornamenti di gioco ai client. Spesso è inferiore alla frequenza dei fotogrammi. Nel nostro gioco, il server opera a 30 tick al secondo.
Se ci limitiamo a renderizzare l'ultimo aggiornamento del gioco, la FPS in sostanza non potrà mai superare 30, perché non riceviamo mai più di 30 aggiornamenti dal server al secondo. Anche se chiamiamo render() 60 volte al secondo, la metà di queste chiamate semplicemente ridisegnerà la stessa cosa, in sostanza non facendo nulla. Un altro problema dell'implementazione naïve è che è soggetta a ritardiCon una connessione Internet ideale, il cliente riceverà l'aggiornamento di gioco esattamente ogni 33 ms (30 al secondo):

Purtroppo, nulla è perfetto. Un quadro più realistico sarebbe il seguente:

Un'implementazione ingenua rappresenta praticamente il caso peggiore in termini di latenza. Se l'aggiornamento di gioco viene ricevuto con un ritardo di 50 ms, allora il cliente subisce ulteriori 50 ms di ritardo, poiché continua a renderizzare lo stato di gioco dall'aggiornamento precedente. Potete immaginare quanto sia scomodo per il giocatore: a causa di ritardi arbitrari, il gioco apparirà scattoso e instabile.
7.2 Stato del cliente migliorato
Apporteremo alcune migliorie all'implementazione ingenua. Innanzitutto, utilizziamo un ritardo di rendering di 100 ms. Ciò significa che lo stato "attuale" del cliente sarà sempre indietro rispetto allo stato di gioco sul server di 100 ms. Ad esempio, se sul server il tempo è 150, allora sul cliente verrà renderizzato lo stato in cui si trovava il server al momento di 50:

Questo ci fornisce un buffer di 100 ms, permettendo di gestire tempi di ricezione imprevedibili degli aggiornamenti di gioco:

Il prezzo da pagare per questo sarà un costante a 100 ms. È un sacrificio minimo per un’esperienza di gioco fluida — la maggior parte dei giocatori (soprattutto quelli occasionali) non notarà nemmeno questo ritardo. È molto più facile per le persone adattarsi a un ritardo costante di 100 ms che giocare con un ritardo imprevedibile.
Possiamo utilizzare anche un'altra tecnica chiamata , che riduce efficacemente le latenze percepite, ma non sarà trattata in questo post.
Un altro miglioramento che utilizziamo è l'interpolazione lineare. A causa del ritardo nel rendering, generalmente siamo almeno un aggiornamento in anticipo rispetto al tempo attuale nel client. Quando viene chiamato getCurrentState(), possiamo eseguire tra gli aggiornamenti del gioco immediatamente prima e dopo il tempo attuale nel client:

Questo risolve il problema del frame rate: ora possiamo rendere fotogrammi unici con qualsiasi frequenza ci sia necessaria!
7.3 Implementazione di uno stato client migliorato
Un esempio di implementazione in src/client/state.js utilizza sia il ritardo di rendering che l'interpolazione lineare, ma è solo temporaneo. Suddividiamo 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);
// Keep only one game update before the current server time
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
const serverTime = currentServerTime();
for (let i = gameUpdates.length - 1; i >= 0; i--) {
if (gameUpdates[i].t <= serverTime) {
return i;
}
}
return -1;
} Iniziamo col capire cosa fa currentServerTime(). Come abbiamo visto prima, ad ogni aggiornamento di gioco è inclusa una timestamp del server. Vogliamo usare il ritardo di rendering per visualizzare l'immagine con un ritardo rispetto al server di 100 ms, ma non sapremo mai qual è l'ora attuale sul server, perché non possiamo sapere quanto tempo ci sia voluto per ricevere ciascuno degli aggiornamenti. Internet è imprevedibile e la sua velocità può variare drasticamente!
Per affrontare questo problema, possiamo usare una ragionevole approssimazione: faremo finta che il primo aggiornamento sia arrivato istantaneamente fingiamo che il primo aggiornamento sia arrivato immediatamente. Se questo fosse vero, conosceremmo il tempo del server in quel momento specifico! Salviamo il timestamp del server in firstServerTimestamp e salviamo il nostro locale (client) timestamp nello stesso momento in gameStart.
Oh, aspetta. Non dovrebbe essere che il tempo del server = il tempo nel client? Perché facciamo distinzione tra “timestamp del server” e “timestamp del client”? È un'ottima domanda! A quanto pare, non sono la stessa cosa. Date.now() restituirà timestamp diversi nel client e nel server, e questo dipende da fattori locali per quelle macchine. Non daremai per scontato che i timestamp siano gli stessi su tutte le macchine.
Ora è chiaro cosa fa currentServerTime(): restituisce il timestamp del server all'ora corrente di rendering. In altre parole, è l'ora attuale del server (firstServerTimestamp <+ (Date.now() - gameStart)) meno il ritardo di rendering (RENDER_DELAY).
Ora vediamo come gestiamo gli aggiornamenti del gioco. Quando riceviamo aggiornamenti dal server, viene chiamato processGameUpdate(), e salviamo il nuovo aggiornamento nell'array gameUpdates. Poi, per controllare l'uso della memoria, rimuoviamo tutte le vecchie aggiornamenti fino a all'aggiornamento base, perché non ci servono più.
Che cos'è un "aggiornamento di base"? È il primo aggiornamento che troviamo, risalendo nel tempo rispetto all'attuale ora del server. Ricordi questo schema?

L'aggiornamento del gioco si trova direttamente a sinistra di "Client Render Time" ed è l'aggiornamento di base.
A cosa serve un aggiornamento di base? Perché possiamo scartare aggiornamenti fino a quello di base? Per chiarire tutto 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'attuale ora di rendering (vedi sopra l'implementazione digetBaseUpdate()). Questo può accadere subito all'inizio del gioco a causa di ritardi nel rendering. In tal caso, utilizziamo l'ultimo aggiornamento ricevuto.baseè l'ultimo aggiornamento che abbiamo. Questo può accadere a causa di ritardi di rete o di una scarsa connessione a Internet. In questo caso, usiamo anche l'ultimo aggiornamento che abbiamo.- Abbiamo aggiornamenti sia prima che dopo l'attuale ora di rendering, quindi possiamo interpolare!
Tutto ciò che rimane in state.js — è un'implementazione dell'interpolazione lineare, che consiste in una matematica semplice (ma noiosa). Se vuoi studiarla da solo, apri state.js con .
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 punto 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');
// Configura un server Express
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Configura Webpack per lo sviluppo
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Servi 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 useremo le nostre configurazioni di Webpack. Le applicheremo in due modi:
- Usa per la ricompilazione automatica dei nostri pacchetti di sviluppo, oppure
- Servire staticamente la cartella
dist/, in cui Webpack registrerà i nostri file dopo la compilazione della produzione.
Un'altra importante attività server.js è configurare il server , che si collega semplicemente al server Express:
server.js, parte 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Configura Express
// ...
const server = app.listen(port);
console.log(`Server in ascolto sulla porta ${port}`);
// Configura 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 degli eventi per il nuovo socket. I gestori degli eventi elaborano i messaggi ricevuti dai client delegando all'oggetto singleton game:
server.js, parte 3
const Game = require('./game');
// ...
// Configura 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 di tipo .io, quindi avremo bisogno di solo un'istanza di Game («Game») – tutti i giocatori giocano su un'unica arena! Nella prossima sezione vedremo come funziona questa classe Game.
2. Game del server
Classe Game contiene la logica più importante lato server. Ha due compiti principali: gestione dei giocatori e simulazione del 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 iniziare 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 sei confuso, torna a server.js). Socket.io assegna a ciascun socket un identificativo unico id, quindi non dobbiamo preoccuparci di questo. Lo chiamerò ID del giocatore.
Tenendo a mente questo, esaminiamo le variabili di istanza nella classe Game:
sockets— è un oggetto che associa l'ID del giocatore al socket collegato a quel giocatore. Ci permette di accedere ai socket in tempo costante tramite i loro ID giocatore.giocatori— è un oggetto che associa l'ID del giocatore all'oggetto code>Player
bullets — è un array di oggetti Bullet, che non ha un ordine definito.
lastUpdateTime — è un timestamp che indica l'ultimo aggiornamento del gioco. Presto vedremo come viene utilizzato.
shouldSendUpdate — è una variabile ausiliaria. Anche il suo utilizzo lo vedremo presto.
I metodi addPlayer(), removePlayer() e handleInput() non necessita spiegazioni, sono usati in server.js. Se hai bisogno di rinfrescarti la memoria, torna un po' più su.
L'ultima riga constructor() avvia il ciclo di aggiornamento del gioco (a 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 punteggio ai giocatori 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 <= 0) {
socket.emit(Constants.MSG_TYPES.GAME_OVER);
this.removePlayer(socket);
}
});
// Invia un aggiornamento di gioco a ciascun giocatore ogni altra volta
if (this.shouldSendUpdate) {
const leaderboard = this.getLeaderboard();
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
socket.emit(
Constants.MSG_TYPES.GAME_UPDATE,
this.createUpdate(player, leaderboard),
);
});
this.shouldSendUpdate = false;
} else {
this.shouldSendUpdate = true;
}
}
// ...
} Metodo update() contiene probabilmente la parte più importante della logica lato server. Vediamo in ordine tutto ciò che fa:
- Calcola quanto tempo
dtè passato dall'ultima voltaupdate(). - Aggiorna ogni proiettile e ne distrugge, se necessario. Vedremo l'implementazione di questa funzionalità più tardi. Per ora, è sufficiente sapere che
bullet.update()restituiscetrue, se il proiettile deve essere distrutto (è uscito dai confini dell'arena). - Aggiorna ogni giocatore e crea un proiettile se necessario. Vedremo anche questa implementazione più tardi —
player.update()può restituire un oggettoBullet. - Controlla le collisioni tra proiettili e giocatori utilizzando
applyCollisions(), che restituisce un array di proiettili che hanno colpito i giocatori. Per ogni proiettile restituito, aumentiamo i punti del giocatore che lo ha sparato (utilizzandoplayer.onDealtDamage()), e poi rimuoviamo il proiettile dall'array.bullets. - Notifica e distrugge tutti i giocatori uccisi.
- Invia aggiornamenti di gioco a tutti i giocatori ogni secondo una volta per chiamata
update(). Questo ci aiuta a monitorare la variabile ausiliaria menzionata soprashouldSendUpdate. Poichéupdate()viene chiamato 60 volte al secondo, inviamo aggiornamenti di gioco 30 volte al secondo. In questo modo, la frequenza dei clock il server è impostato a 30 tick/s (ne abbiamo parlato nella prima parte riguardo alla frequenza dei tick).
Perché inviare gli aggiornamenti del gioco solo ogni tanto ? Per risparmiare larghezza di banda. 30 aggiornamenti di gioco al secondo sono davvero tanti!
Perché non chiamare
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 la quantità di chiamateupdate(), perché è un compito computazionalmente costoso — 60 al secondo è più che sufficiente.
Il resto della classe Game è composto 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 al punteggio, prende i cinque migliori e restituisce per ciascuno il nome utente e il punteggio.
createUpdate() è utilizzato in update() per creare aggiornamenti di gioco che vengono inviati ai giocatori. Il suo compito principale è chiamare i metodi serializeForUpdate(), implementati per le classi Player e Bullet. Si noti che trasmette a ciascun giocatore i dati solo sui giocatori più vicini e sugli oggetti – non è necessario trasmettere 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 e mobili di forma rotonda. Per sfruttare questa somiglianza tra giocatori e proiettili, iniziamo implementando una classe 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,
};
}
} Qui non succede nulla di complicato. 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 vero 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 breve! Abbiamo aggiunto a Object solo le seguenti estensioni:
- Utilizzo del pacchetto per la generazione casuale
iddel proiettile. - Aggiunta del campo
parentID, per poter tenere traccia del giocatore che ha creato questo proiettile. - Aggiunta del valore di ritorno a
update(), che è uguale atrue, se il proiettile è al di fuori 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;
}
// Returns a newly created bullet, or null.
update(dt) {
super.update(dt);
// Update score
this.score += dt * Constants.SCORE_PER_SECOND;
// Make sure the player stays in bounds
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
// Fire a bullet, if needed
this.fireCooldown -= dt;
if (this.fireCooldown <= 0) {
this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
return new Bullet(this.id, this.x, this.y, this.direction);
}
return null;
}
takeBulletDamage() {
this.hp -= Constants.BULLET_DAMAGE;
}
onDealtDamage() {
this.score += Constants.SCORE_BULLET_HIT;
}
serializeForUpdate() {
return {
...(super.serializeForUpdate()),
direction: this.direction,
hp: this.hp,
};
}
} I giocatori sono più complessi dei proiettili, quindi in questa classe devono essere conservati anche alcuni altri campi. Il suo metodo update() esegue un lavoro maggiore, in particolare restituisce solo il proiettile appena creato, se non ce ne sono rimasti. fireCooldown (ricordate, ne abbiamo parlato nella sezione precedente?). Espande anche il metodo serializeForUpdate(), poiché dobbiamo includere nell'aggiornamento del gioco campi aggiuntivi per il giocatore.
La presenza di una classe base Object — un passo importante per evitare la ripetizione del codice. Ad esempio, senza una classe Object ogni oggetto di gioco deve avere la stessa implementazione distanceTo(), e la sincronizzazione del copia e incolla di tutte queste implementazioni in più file sarebbe un incubo. Questo diventa particolarmente importante per grandi progetti, quando il numero delle classi estendenti Object aumenta.
4. Riconoscimento delle collisioni
L'unica cosa che ci resta è riconoscere quando i proiettili colpiscono i giocatori! Ricordate questo frammento di codice dal metodo update() nella classe Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Applica le collisioni, assegna ai giocatori un punteggio per i colpi ricevuti
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 un metodo applyCollisions(), che restituisca tutti i proiettili che hanno colpito i giocatori. Fortunatamente, non è così difficile farlo, perché
- tutti gli oggetti che collidono sono cerchi, e questa è una forma semplice per implementare il riconoscimento delle collisioni.
- Abbiamo già un metodo
distanceTo(), che abbiamo implementato nel capitolo precedente nella classeObject.
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 bisogna prestare attenzione ad un paio di aspetti:
- Il proiettile non deve colpire il giocatore che lo ha creato. Questo può essere ottenuto confrontando
bullet.parentIDconplayer.id. - Il proiettile deve colpire solo una volta in caso di collisione simultanea con più giocatori. Risolveremo questo problema usando l'operatore
break: una volta trovato un giocatore che ha colliso con il proiettile, interrompiamo la ricerca e passiamo al proiettile successivo.
Fine
E questo è tutto! Abbiamo coperto tutto ciò che è necessario sapere per creare un gioco web del genere .io. E adesso? Crea il tuo gioco .io!
Tutto il codice dell'esempio è open source e disponibile su .
Fonte: habr.com
