
Uitgebracht in 2015 werd de grondlegger van een nieuw genre , waarvan de populariteit sindsdien sterk is toegenomen. De groei van de populariteit van .io-spellen heb ik zelf ervaren: in de afgelopen drie jaar heb ik .
Voor het geval je nog nooit van dergelijke spellen hebt gehoord: dit zijn gratis multiplayer webspellen waaraan je gemakkelijk kunt deelnemen (er is geen account vereist). Gewoonlijk draaien ze om het strijden tussen veel tegenstanders op ƩƩn arena. Andere beroemde spellen in het .io-genre zijn: en .
In dit bericht gaan we bekijken hoe je een .io-spel vanaf nul kunt maken. Hiervoor heb je alleen kennis van Javascript nodig: je moet dingen begrijpen zoals de syntaxis , het sleutelwoord this en . Zelfs als je Javascript niet perfect kent, kun je nog steeds het grootste deel van het bericht begrijpen.
Voorbeeld van een .io-spel
Ter ondersteuning van het leren zullen we verwijzen naar . Probeer het te spelen!

Het spel is behoorlijk eenvoudig: je bestuurt een ruimteschip op een arena met andere spelers. Je schip schiet automatisch projectielen en je probeert andere spelers te raken, terwijl je tegelijkertijd hun projectielen probeert te ontwijken.
1. Korte overzicht/structuur van het project
Ik raad je aan van het voorbeeldspel, zodat je mij kunt volgen.
In het voorbeeld worden de volgende dingen gebruikt:
- is het populairste webframework voor Node.js, dat de webserver van het spel beheert.
- is een websocketbibliotheek voor het uitwisselen van gegevens tussen de browser en de server.
- is een modulebeheerder. Over waarom je Webpack moet gebruiken, kun je lezen .
Dit is hoe de projectdirectorystructuur eruit ziet:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublic/
Alles in de map public/ zal statisch door de server worden aangeboden. In public/assets/ staan de afbeeldingen die door ons project worden gebruikt.
src/
Alle broncode staat in de map src/. De namen . āāā client.py āāā Dockerfile 0 mappen, 2 bestanden en server/ moeten eruitzien: . āāā Dockerfile āāā index.html āāā server.py 0 mappen, 3 bestanden spreken voor zich, en shared/ bevat een constante bestand dat wordt geĆÆmporteerd door zowel de client als de server.
2. Builds/projectparameters
Zoals eerder vermeld, gebruiken we een modulebeheerder voor het bouwen van het project. Laten we een kijkje nemen naar onze Webpack-configuratie:
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',
}),
],
};De belangrijkste regels hier zijn de volgende:
src/client/index.jsā dit is het toegangspunt voor de Javascript (JS) client. Webpack zal hier beginnen en andere geĆÆmporteerde bestanden recursief zoeken.- De uitvoer-JS van onze Webpack-bundel zal zich bevinden in de map
dist\/. Ik zal dit bestand onze JS-bundel. - Wij gebruiken noemen, en specifiek de configuratie van voor het transpilen van onze JS-code voor oudere browsers.
- We gebruiken een plugin om alle CSS die door de JS-bestanden worden aangesproken te extraheren en ze op ƩƩn plek samen te voegen. Ik zal dit onze CSS-bundel.
noemen. Je hebt misschien vreemde bestandsnamen voor de bundels opgemerkt '[name].[contenthash].ext'. Er zijn Webpack: [name] zal worden vervangen door de naam van het toegangspunt (in ons geval is dat game), terwijl [contenthash] zal worden vervangen door de hash van de inhoud van het bestand. We doen dit om ā we kunnen de browsers vertellen om onze JS-bundels oneindig te cachen, omdat als de bundel verandert, verandert ook de bestandsnaam (de contenthash). Het eindresultaat zal een bestandsnaam zijn zoals game.dbeee76e91a97d0c7207.js.
Bestand webpack.common.js ā dit is het basisconfiguratiebestand dat we importeren in de ontwikkelings- en productieconfiguraties. Hier is bijvoorbeeld de ontwikkelingsconfiguratie:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('.\/webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); Voor efficiƫntie gebruiken we tijdens de ontwikkeling webpack.dev.js, en schakelt naar webpack.prod.js, om de bundelgrootte te optimaliseren bij het uitrollen naar productie.
Lokale setup
Ik raad aan om het project op je lokale machine op te zetten, zodat je de stappen kunt volgen die in deze post staan. De setup is eenvoudig: ten eerste moet en op je systeem zijn geĆÆnstalleerd.
$ git clone https:\/\/github.com\/vzhou842\/example-.io-game.git
$ cd example-.io-game
$ npm installen u bent klaar om te beginnen! Om de ontwikkelingsserver te starten, hoeft u alleen maar
$ npm run developte bezoeken in uw webbrowser op . De ontwikkelingsserver zal automatisch opnieuw JS- en CSS-pakketten samenstellen tijdens het wijzigen van de code ā ververs gewoon de pagina om alle wijzigingen te zien!
3. Klantpunten
Laten we beginnen met de eigenlijke gamecode. Eerst hebben we een pagina nodig index.html, die de browser zal laden wanneer de site wordt bezocht. Onze pagina zal vrij eenvoudig zijn:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Een voorbeeld .io spel</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="gebruikersnaam" />
<button id="play-button">SPELEN</button>
</div>
</body>
</html>Dit codevoorbeeld is een beetje vereenvoudigd voor de duidelijkheid, hetzelfde zal ik doen met vele andere voorbeelden in de post. De volledige code is altijd te bekijken op .
We hebben:
- (
<canvas>), dat we zullen gebruiken voor het renderen van het spel. <link>om ons CSS-pakket toe te voegen.<script>om ons Javascript-pakket toe te voegen.- Hoofdmenu met gebruikersnaam
en een knop 'PLAY' ().
Nadat de startpagina in de browser is geladen, begint de Javascript-code uit te voeren, beginnend met het JS-invoerveld: 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 = () => {
// Speel!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});Dit kan moeilijk lijken, maar er gebeurt hier eigenlijk niet zoveel:
- Meerdere andere JS-bestanden importeren.
- CSS importeren (zodat Webpack weet dat hij deze moet opnemen in ons CSS-pakket).
- Start
connect()om verbinding te maken met de server en te startendownloadAssets()om afbeeldingen te downloaden die nodig zijn voor het renderen van het spel. - Na het voltooien van fase 3 wordt het hoofdmenu weergegeven (
playMenu). - Een handler instellen voor de knop 'PLAY'. Wanneer de knop wordt ingedrukt, initialiseert de code het spel en laat de server weten dat we klaar zijn om te spelen.
Het belangrijkste 'vlees' van onze client-serverlogica bevindt zich in de bestanden die zijn geïmporteerd door het bestand index.js. Nu zullen we ze allemaal één voor één bekijken.
4. Gegevensuitwisseling tussen de client
In deze game gebruiken we een goed bekende bibliotheek voor communicatie met de server . Socket.io heeft ingebouwde ondersteuning , die goed geschikt zijn voor bidirectionele communicatie: we kunnen berichten naar de server sturen en de server kan ons berichten sturen over dezelfde verbinding.
We zullen ƩƩn bestand hebben src/client/networking.js, dat zich bezighoudt met alle communicaties met de 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('Verbonden met de server!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
\/\/ Registreer callbacks
socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
})
);
export const play = username => {
socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};
export const updateDirection = dir => {
socket.emit(Constants.MSG_TYPES.INPUT, dir);
};Deze code is ook iets ingekort voor duidelijkheid.
In dit bestand vinden drie belangrijke acties plaats:
- We proberen verbinding te maken met de server.
connectedPromisewordt alleen opgelost wanneer we een verbinding hebben gemaakt. - Als de verbinding met succes tot stand is gebracht, registreren we callback-functies (
processGameUpdate()enonGameOver()) voor de berichten die we van de server kunnen ontvangen. - We exporteren
play()enupdateDirection(), zodat andere bestanden ze kunnen gebruiken.
5. Rendering van de client
Het is tijd om de afbeelding op het scherm weer te geven!
ā¦maar voordat we dat kunnen doen, moeten we alle afbeeldingen (middelen) downloaden die hiervoor nodig zijn. Laten we een middelenmanager schrijven:
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(`Gedownload ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `\/assets\/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; Het beheren van middelen is niet zo moeilijk! Het idee is om een object te bewaren assets, dat de naam van het bestand koppelt aan de waarde van het object Afbeelding. Wanneer het middel is gedownload, slaan we het op in het object assets voor snelle toegang in de toekomst. Wanneer de download van elk individueel middel is toegestaan (dat wil zeggen, wanneer de middelen zijn gedownload), staan we all downloadPromise Na het downloaden van de middelen kunnen we beginnen met het renderen. Zoals eerder vermeld, gebruiken we.
HTML5 Canvas (<canvas>). Ons spel is vrij simpel, dus het is voldoende om alleen het volgende weer te geven:
- Achtergrond
- Speler schip
- Andere spelers in het spel
- Projectielen
Hier zijn belangrijke onderdelen src/client/render.js, die specifiek de bovenstaande vier punten weergeven:
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;
// Verkrijg de canvas grafische context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Maak het canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Teken achtergrond
renderBackground(me.x, me.y);
// Teken alle projectielen
bullets.forEach(renderBullet.bind(null, me));
// Teken alle spelers
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Helper functies hier uitgesloten
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Deze code is ook ingekort voor duidelijkheid.
render() ā de hoofd functie van dit bestand. startRendering() en stopRendering() beheersen de activering van de render-loop met een snelheid van 60 FPS.
Specifieke implementaties van afzonderlijke helper-functies voor rendering (bijvoorbeeld renderBullet()) zijn niet zo belangrijk, maar hier is een simpel voorbeeld:
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,
);
} Let op dat we de methode getAsset()gebruiken, die we eerder hebben gezien in asset.js!
Als je geĆÆnteresseerd bent in het verkennen van andere helper-functies voor rendering, lees dan de rest van .
6. Klantinput
Het is tijd om het spel speelbaar te maken! De besturingsschema zal heel eenvoudig zijn: je kunt de muis (op de computer) of het aanraaken van het scherm (op het mobiele apparaat) gebruiken om de bewegingsrichting te veranderen. Om dit te implementeren, registreren we voor Mouse- en Touch-evenementen.
Dit wordt allemaal geregeld door 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() en onTouchInput() ā dit zijn Event Listeners die worden aangeroepen updateDirection() (van networking.js) bij het optreden van een invoerevenement (bijvoorbeeld bij het verplaatsen van de muis). updateDirection() verzorgt de communicatie met de server, die het invoerevenement verwerkt en de status van het spel dienovereenkomstig bijwerkt.
7. Klantstatus
Dit gedeelte is het meest complexe deel van het eerste deel van de post. Maak je geen zorgen als je het niet in ƩƩn keer begrijpt! Je kunt het zelfs overslaan en later terugkomen.
Het laatste puzzelstukje dat nodig is om de klant-servercode te voltooien, is state. Vergeet het codefragment uit het gedeelte 'Rendering van de client' niet?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Voer de rendering uit
// ...
} getCurrentState() moet in staat zijn om ons de huidige status van het spel in de client te geven op elk moment op basis van updates die van de server worden ontvangen. Hier is een voorbeeld van een spelupdate die de server kan verzenden:
{
"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": "Speler",
"score": 3
}
]
}Elke spelupdate bevat vijf identieke velden:
- t: een tijdstempel van de server die het moment van creatie van deze update aangeeft.
- me: informatie over de speler die deze update ontvangt.
- others: een array met informatie over andere spelers die aan hetzelfde spel deelnemen.
- bullets: een array met informatie over projectielen in het spel.
- leaderboard: actuele gegevens van het leaderboard. In deze post zullen we dit niet meenemen.
7.1 NaĆÆeve klantstatus
NaĆÆeve implementatie getCurrentState() kan alleen de data van de laatst ontvangen spelupdate direct retourneren.
naive-state.js
let lastGameUpdate = null;
// Verwerk een nieuw ontvangen spelupdate.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Mooi en duidelijk! Maar als het zo eenvoudig was. Een van de redenen waarom zo'n implementatie problematisch is, is: het beperkt de framerate van de rendering tot de klokfrequentie van de server.
Framerate: het aantal frames (d.w.z. aanroepen
render()) per seconde, of FPS. In games wordt meestal gestreefd naar minstens 60 FPS.
Tick Rate: de frequentie waarmee de server spelupdates naar de clients verzendt. Vaak is deze lager dan de framerate. In ons spel werkt de server met een tickrate van 30 ticks per seconde.
Als we alleen de laatste update van het spel renderen, kan de FPS in feite nooit hoger zijn dan 30, omdat we van de server nooit meer dan 30 updates per seconde ontvangen. Zelfs als we het aanroepen render() 60 keer per seconde, dan zou de helft van deze aanroepen gewoon hetzelfde opnieuw renderen, wat in feite niets doet. Een ander probleem van de naĆÆeve implementatie is dat het onderhevig is aan vertragingen. Bij perfecte internetsnelheid ontvangt de client de spelupdate precies om de 33 ms (30 per seconde):

Helaas is niets perfect. Een realistischer beeld zou zijn:

De naĆÆeve implementatie is vrijwel de slechtste situatie als het gaat om vertragingen. Als de spelupdate met 50 ms vertraging wordt ontvangen, wordt de client vertraagd met een extra 50 ms, omdat hij nog steeds de staat van het spel renderen vanuit de eerdere update. Je kunt je voorstellen hoe ongemakkelijk dit is voor de speler: door willekeurige vertragingen zal het spel schokkerig en onstabiel lijken.
7.2 Verbeterde clientstatus
We zullen enkele verbeteringen aan de naĆÆeve implementatie aanbrengen. Ten eerste gebruiken we een renderingvertraging van 100 ms. Dit betekent dat de "huidige" status van de client altijd 100 ms achterloopt op de status van het spel op de server. Bijvoorbeeld, als de tijd op de server gelijk is aan 150, dan zal de client de status renderen waarin de server was op tijd 50:

Dit geeft ons een buffer van 100 ms, waarmee we onvoorspelbare tijden voor het ontvangen van spelupdates kunnen doorstaan:

De prijs die we betalen is een constante van 100 ms. Dit is een geringe opoffering voor een soepele game-ervaring ā de meeste spelers (vooral casuals) zullen deze vertraging zelfs niet opmerken. Mensen kunnen zich veel makkelijker aanpassen aan een constante vertraging van 100 ms dan spelen met onvoorspelbare vertraging.
We kunnen ook een andere techniek gebruiken die wordt genoemd, die goed omgaat met het verminderen van waargenomen vertragingen, maar deze zal in deze post niet worden behandeld.
Een andere verbetering die we toepassen is lineaire interpolatie. Vanwege de renderingvertraging zijn we doorgaans minimaal ƩƩn update voor op de huidige tijd in de client. Wanneer wordt aangeroepen getCurrentState(), kunnen we tussen game-updates direct voor en na het huidige tijdstip in de client:

Dit lost het probleem met de framerate op: we kunnen nu unieke frames renderen met elke gewenste framerate!
7.3 Implementatie van de verbeterde clientstatus
Voorbeeldimplementatie in src/client/state.js maakt gebruik van zowel rendervertraging als lineaire interpolatie, maar dit is van korte duur. Laten we de code in twee delen splitsen. Hier is het eerste deel:
state.js, deel 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);
// Houd alleen ƩƩn game-update voor de huidige servertijd
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Geeft de index van de basisupdate terug, de eerste game-update vóór
// de huidige servertijd, of -1 als 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;
} Allereerst moeten we begrijpen wat currentServerTime(). Zoals we eerder zagen, wordt er bij elke game-update een server-timestamp opgenomen. We willen de rendervertraging gebruiken om het beeld met 100 ms achter te laten op de server, maar we zullen nooit weten wat de huidige tijd op de server is, omdat we niet kunnen weten hoe lang het heeft geduurd voordat een van de updates ons bereikte. Het internet is onvoorspelbaar en de snelheid kan sterk variƫren!
Om dit probleem te omzeilen, kunnen we een redelijke benadering gebruiken: we doen alsof de eerste update onmiddellijk is aangekomen. Als dat waar zou zijn, zouden we de servertijd op dit specifieke moment weten! We slaan de server-timestamp op in firstServerTimestamp en slaan onze lokale (client) timestamp op datzelfde moment in gameStart.
Oh, wacht even. Moet de tijd op de server niet gelijk zijn aan de tijd in de client? Waarom maken we onderscheid tussen 'server-timestamp' en 'client-timestamp'? Dat is een uitstekende vraag! Blijkbaar is het niet hetzelfde. Date.now() zal verschillende timestamps teruggeven in de client en server, en dit hangt af van lokale factoren op die machines. Veronderstel nooit dat timestamps identiek zijn op alle machines.
Nu begrijpen we wat doet currentServerTime(): het geeft de timestamp van de server voor de huidige renderingtijd terug.. Met andere woorden, dit is de huidige tijd van de server (firstServerTimestamp < + (Date.now() - gameStart)) min de rendervertraging (RENDER_DELAY).
Laten we nu eens kijken hoe we game-updates verwerken. Wanneer er een update van de server binnenkomt, wordt processGameUpdate(), en we slaan de nieuwe update op in de array gameUpdates. Vervolgens, om het geheugengebruik te controleren, verwijderen we alle oude updates tot de basisupdate, omdat we die niet meer nodig hebben.
Wat is een 'basisupdate'? Het is de eerste update die we vinden als we teruggaan vanaf de huidige tijd van de server. Herinner je deze figuur?

De game-update is rechtstreeks links van de 'Client Render Time' en is de basisupdate.
Waarvoor wordt de basisupdate gebruikt? Waarom kunnen we updates tot de basis uitsluiten? Om dit te begrijpen, laten we eindelijk de implementatie bekijken getCurrentState():
state.js, deel 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),
};
}
}We verwerken drie gevallen:
base < 0betekent dat er geen updates zijn voor de huidige render tijd (zie hierboven de implementatiegetBaseUpdate()). Dit kan gebeuren direct aan het begin van het spel door de rendervertraging. In dat geval gebruiken we de nieuwste ontvangen update.baseis de laatste update die we hebben. Dit kan ook gebeuren door netwerkvertraging of slechte internetverbinding. In dit geval gebruiken we ook de nieuwste update die we hebben.- We hebben updates voor en na de huidige render tijd, dus we kunnen interpoleren!
Alles wat overblijft in state.js is de implementatie van lineaire interpolatie, wat eenvoudige (maar saaie) wiskunde is. Als je het zelf wilt bestuderen, open dan state.js en een werkende opdracht krijgen. .
Deel 2. Backend-server
In dit deel bekijken we de Node.js backend die ons beheert .
1. Het instappunt van de server
Voor het beheren van de webserver gebruiken we het populaire webframework voor Node.js genaamd . De opstelling ervan wordt geregeld door ons server instappunt bestand src/server/server.js:
server.js, deel 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../webpack.dev.js');
// Setup een Express-server
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Setup Webpack voor ontwikkeling
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Statisch de dist/ map in productie serveren
app.use(express.static('dist'));
}
// Luister op poort
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server luistert op poort ${port}`);Vergeet niet dat we in het eerste deel Webpack hebben besproken? Hier gaan we onze Webpack-configuraties gebruiken. We zullen ze op twee manieren toepassen:
- Gebruik voor automatische herbouw van onze ontwikkelpakketten, of
- Statisch de map
dist\/, waarin Webpack onze bestanden zal opslaan na productieopbouw.
Een andere belangrijke taak server.js is het opzetten van de server , die simpelweg verbinding maakt met de Express-server:
server.js, deel 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Setup Express
// ...
const server = app.listen(port);
console.log(`Server luistert op poort ${port}`);
// Setup socket.io
const io = socketio(server);
// Luister naar socket.io verbindingen
io.on('connection', socket => {
console.log('Speler verbonden!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Na een succesvolle verbinding van socket.io met de server, stellen we de evenementenhandlers in voor de nieuwe socket. De evenementenhandlers verwerken berichten van klanten door ze door te geven aan het singleton-object game:
server.js, deel 3
const Game = require('./game');
// ...
// Setup het spel
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} We creĆ«ren een .io game, dus we hebben maar ƩƩn instantie nodig Game (āGameā) ā alle spelers spelen op dezelfde arena! In het volgende gedeelte zullen we bekijken hoe deze klasse werkt Game.
2. Game server
Klasse Game bevat de belangrijkste logica aan de serverzijde. Het heeft twee hoofdtaken: beheer van spelers en simulatie van het spel.
Laten we beginnen met de eerste taak - het beheer van spelers.
game.js, deel 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;
\/\/ Genereer een positie om deze speler te starten.
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 dit spel zullen we spelers identificeren op basis van het veld id hun socket.io (als je in de war bent, ga dan terug naar server.js). Socket.io kent elk socket automatisch een unieke id, dus daar hoeven we ons geen zorgen over te maken. Ik zal het noemen speler ID.
Vergeet dit niet, laten we de instantievariabelen in de klasse bekijken Game:
socketsā dit is een object dat de ID van de speler aan de socket koppelt die aan de speler is gekoppeld. Het stelt ons in staat om toegang te krijgen tot sockets op basis van hun spelers-ID in constante tijd.playersā dit is een object dat de ID van de speler aan het object code>Player koppelt.
bullets ā dit is een array van objecten Bullet, zonder specifieke volgorde.
lastUpdateTime ā dit is een tijdstempel van de laatste update van het spel. Binnenkort zullen we zien hoe deze gebruikt wordt.
shouldSendUpdate ā dit is een hulpparameter. We zullen binnenkort ook zien hoe deze wordt gebruikt.
Methoden addPlayer(), removePlayer() en handleInput() hebben geen uitleg nodig, ze worden gebruikt in server.js. Als je je geheugen moet opfrissen, ga dan iets verder omhoog.
De laatste regel constructor() start de updatecyclus van het spel (met een frequentie van 60 updates/s):
game.js, deel 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ Bereken de verstreken tijd
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Update elke kogel
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Vernietig deze kogel
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Update elke speler
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ Pas botsingen toe, geef spelers punten voor het raken van kogels
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),
);
\/\/ Controleer of er spelers dood zijn
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;
}
}
\/\/ ...
} Methode update() bevat waarschijnlijk het belangrijkste deel van de logica aan de serverzijde. Laten we chronologisch opsommen wat het doet:
- Het berekent hoeveel tijd
dtverstreken is sinds de laatsteupdate(). - Het werkt elke kogel bij en vernietigt ze indien nodig. De implementatie van deze functionaliteit zullen we later zien. Voor nu is het voldoende om te weten dat
bullet.update()geeft hijtrue, als de kogel moet worden vernietigd (het is buiten de arena gekomen). - Het werkt elke speler bij en creĆ«ert indien nodig een kogel. Deze implementatie zullen we ook later zien ā
player.update()kan een object retournerenBullet. - Het controleert botsingen tussen kogels en spelers met behulp van
applyCollisions(), dat een lijst geeft van kogels die spelers hebben geraakt. Voor elke teruggegeven kogel verhogen we de punten van de speler die het heeft afgevuurd (met behulp vanplayer.onDealtDamage()), en vervolgens verwijderen we de kogel uit de lijstbullets. - Het stelt alle gedode spelers op de hoogte en vernietigt ze.
- Het verzendt een update van het spel naar alle spelers iedere tweede keer bij aanroep
update(). Dit helpt ons de eerder genoemde hulpparameter bij te houdenshouldSendUpdate. Omdatupdate()het wordt 60 keer/s aangeroepen, we sturen game-updates 30 keer/s. Zo, de kloksnelheid van de server is 30 cycles/s (we spraken over kloksnelheid in het eerste deel).
Waarom zouden we game-updates alleen maar sturen elke keer ? Voor bandbreedtebesparing. 30 game-updates per seconde is heel veel!
Waarom roepen we dan niet gewoon
update()30 keer per seconde aan? Voor een verbeterde gamesimulatie. Hoe vaker het wordt aangeroepen,update()hoe nauwkeuriger de gamesimulatie zal zijn. Maar we moeten niet te veel op het aantal aanroepen hameren,update()want het is een rekenintensievere taak ā 60 per seconde is ruim voldoende.
Het resterende deel van de klasse Game bestaat uit hulpfuncties die worden gebruikt in update():
game.js, deel 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() is vrij eenvoudig ā het sorteert spelers op basis van score, neemt de top vijf en retourneert voor elke speler de gebruikersnaam en score.
createUpdate() wordt gebruikt in update() voor het genereren van game-updates die naar spelers worden gestuurd. Het hoofddoel is om de methoden aan te roepen serializeForUpdate(), geĆÆmplementeerd voor de klassen Player en Bullet. Let op dat het alleen gegevens over de dichtstbijzijnde spelers en projectielen doorgeeft ā er is geen behoefte om informatie door te geven over game-objecten die ver weg zijn van de speler!
3. Game-objecten op de server
In onze game lijken projectielen en spelers eigenlijk veel op elkaar: het zijn abstracte, ronde, beweegbare game-objecten. Om van deze gelijkenis tussen spelers en projectielen gebruik te maken, laten we beginnen met het implementeren van een basis klasse 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,
};
}
} Hier gebeurt niets ingewikkelds. Deze klasse zal een goede basis vormen voor uitbreiding. Laten we eens kijken naar de klasse Bullet gebruikt 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;
}
// Retourneert true als de kogel vernietigd moet worden
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Implementatie Bullet heel kort! We hebben toegevoegd aan Object alleen de volgende uitbreidingen:
- Het gebruik van het pakket voor willekeurige generatie van
idde kogel. - Het toevoegen van het veld
parentID, zodat we de speler kunnen volgen die deze kogel heeft gemaakt. - Het toevoegen van een retourwaarde in
update(), die gelijk is aantrue, als de kogel buiten de arena is (onthoud dat we dit in het vorige gedeelte hebben besproken?).
Laten we verder gaan met 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;
}
// Retourneert een nieuw aangemaakte kogel, of null.
update(dt) {
super.update(dt);
// Bijwerken van score
this.score += dt * Constants.SCORE_PER_SECOND;
// Zorg ervoor dat de speler binnen de grenzen blijft
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
// Vuurt een kogel af, indien nodig
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,
};
}
} Spelers zijn ingewikkelder dan kogels, daarom moeten er nog een paar velden in deze klasse worden opgeslagen. De methode update() doet meer werk, met name het retourneren van de net aangemaakte kogel, als er geen meer zijn. fireCooldown (onthoud dat we dit in het vorige gedeelte hebben besproken?). Ook breidt hij de methode uit serializeForUpdate(), omdat we extra velden voor de speler in de game-update moeten opnemen.
Het hebben van een basis klasse Object is een belangrijke stap om herhaling van code te vermijden.Bijvoorbeeld, zonder de klasse Object zou elk game-object een identieke implementatie moeten hebben van distanceTo(), en het synchroniseren van al deze implementaties in verschillende bestanden zou een nachtmerrie zijn. Dit wordt vooral belangrijk voor grote projecten, wanneer het aantal uitbreidende Object klassen toeneemt.
4. Het herkennen van botsingen
Het enige wat we nog moeten doen, is herkennen wanneer projectielen spelers raken! Denk terug aan dit codefragment uit de methode update() in de klasse Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Pas botsingen toe, geef spelers punten voor het raken van kogels
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),
);
// ...
}
} We moeten een methode implementeren applyCollisions(), die alle projectielen retourneert die spelers hebben geraakt. Gelukkig is dit niet zo moeilijk, want
- Alle botsende objecten zijn cirkels, wat een eenvoudig te implementeren vorm van botsingsherkenning is.
- We hebben al een methode
distanceTo(), die we in de vorige sectie hebben geĆÆmplementeerd in de klasseObject.
Dit is hoe onze implementatie van botsingsherkenning eruitziet:
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;
}Deze eenvoudige botsingsherkenning is gebaseerd op het feit dat twee cirkels botsen als de afstand tussen hun centra kleiner is dan de som van hun stralen. Hier is een geval waarin de afstand tussen de centra van twee cirkels precies gelijk is aan de som van hun stralen:

Hier moeten we nog naar een paar andere aspecten kijken:
- Een projectiel mag de speler die het heeft gemaakt niet raken. Dit kunnen we bereiken door te vergelijken met
bullet.parentIDmetplayer.id. - Een projectiel moet in het uiterste geval slechts ƩƩn keer raken bij gelijktijdige botsing met meerdere spelers. Deze taak lossen we op met de operator
break: zodra we een speler hebben gevonden die met de kogel in botsing komt, stoppen we de zoektocht en gaan we verder met de volgende kogel.
Einde
Dat is alles! We hebben alles besproken wat je moet weten om een webspel in de .io-genre te maken. Wat nu? Maak je eigen .io-spel!
De volledige voorbeeldcode is open source en te vinden op .
Bron: habr.com
