Een multiplayer-webgame maken in het .io-genre

Een multiplayer-webgame maken in het .io-genre
Uitgebracht in 2015 Agar.io werd de voorloper van een nieuw genre spellen .iodie sindsdien in populariteit is toegenomen. Ik heb persoonlijk de stijgende populariteit van .io-games ervaren: de afgelopen drie jaar heb ik dat ook gedaan heeft twee games in dit genre gemaakt en verkocht..

Als je nog nooit van deze spellen hebt gehoord: dit zijn gratis multiplayer-webspellen die gemakkelijk te spelen zijn (geen account vereist). Meestal plaatsen ze veel tegenstanders in één arena. Andere bekende .io-spellen: Slither.io и Diep.io.

In dit bericht zullen we onderzoeken hoe maak een geheel nieuwe .io-game. Hiervoor is alleen kennis van Javascript voldoende: je moet zaken als syntaxis begrijpen ES6, trefwoord this и Beloftes. Zelfs als uw kennis van Javascript niet perfect is, kunt u het grootste deel van de post nog steeds begrijpen.

Voorbeeld van een .io-spel

Voor leerhulp verwijzen we naar voorbeeldspel .io. Probeer het te spelen!

Een multiplayer-webgame maken in het .io-genre
Het spel is vrij eenvoudig: je bestuurt een schip in een arena met andere spelers. Je schip vuurt automatisch projectielen af ​​en je probeert andere spelers te raken terwijl je hun projectielen ontwijkt.

1. Kort overzicht/structuur van het project

aanraden broncode downloaden voorbeeldspel zodat je mij kunt volgen.

In het voorbeeld wordt het volgende gebruikt:

  • Uitdrukken is het populairste Node.js-webframework dat de webserver van de game beheert.
  • socket.io: — websocketbibliotheek voor het uitwisselen van gegevens tussen de browser en de server.
  • webpack - modulebeheerder. U kunt lezen waarom u Webpack moet gebruiken. hier.

Hier ziet u hoe de projectdirectorystructuur eruit ziet:

public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

openbaar/

Alles staat in de map public/ wordt statisch verzonden door de server. IN public/assets/ bevat afbeeldingen die door ons project worden gebruikt.

src /

Alle broncode bevindt zich in de map src/. Titels client/ и server/ voor zichzelf spreken en shared/ bevat een constantenbestand dat door zowel de client als de server wordt geïmporteerd.

2. Assemblages/projectinstellingen

Zoals hierboven vermeld, gebruiken we de modulemanager om het project te bouwen. webpack. Laten we eens kijken 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:

  • src/client/index.js is het toegangspunt van de Javascript (JS)-client. Webpack zal vanaf hier starten en recursief zoeken naar andere geïmporteerde bestanden.
  • De uitvoer-JS van onze Webpack-build bevindt zich in de map dist/. Ik noem dit bestand ons js-pakket.
  • We gebruiken Babel, en vooral de configuratie @babel/preset-env om onze JS-code voor oudere browsers te transpileren.
  • We gebruiken een plug-in om alle CSS waarnaar wordt verwezen door JS-bestanden te extraheren en deze op één plek te combineren. Ik noem het de onze css-pakket.

Het is je misschien opgevallen dat er vreemde pakketbestandsnamen zijn '[name].[contenthash].ext'. Ze bevatten vervangingen van bestandsnamen Webpakket: [name] wordt vervangen door de naam van het invoerpunt (in ons geval this game), en [contenthash] wordt vervangen door een hash van de inhoud van het bestand. Wij doen het om optimaliseer het project voor hashing - u kunt browsers vertellen dat ze onze JS-pakketten voor onbepaalde tijd in de cache moeten opslaan, omdat als een pakket verandert, verandert ook de bestandsnaam (veranderingen contenthash). Het eindresultaat is de naam van het weergavebestand game.dbeee76e91a97d0c7207.js.

file webpack.common.js is het basisconfiguratiebestand dat we importeren in de ontwikkelings- en voltooide projectconfiguraties. Hier is een voorbeeld van een 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 in het ontwikkelingsproces webpack.dev.jsen schakelt over naar webpack.prod.jsom pakketgroottes te optimaliseren bij implementatie in productie.

Lokale instelling

Ik raad aan om het project op een lokale computer te installeren, zodat je de stappen in dit bericht kunt volgen. De installatie is eenvoudig: eerst moet het systeem geïnstalleerd zijn Knooppunt и NPM. Vervolgens moet je doen

$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install

en je bent klaar om te gaan! Om de ontwikkelingsserver te starten, hoeft u alleen maar te rennen

$ npm run develop

en ga naar de webbrowser localhost: 3000. De ontwikkelingsserver zal de JS- en CSS-pakketten automatisch opnieuw opbouwen als de code verandert. Vernieuw gewoon de pagina om alle wijzigingen te zien!

3. Toegangspunten voor klanten

Laten we naar de spelcode zelf gaan. Eerst hebben we een pagina nodig index.html, wanneer u de site bezoekt, laadt de browser deze eerst. Onze pagina zal vrij eenvoudig zijn:

index.html

Een voorbeeld van een .io-spel  TONEELSTUK

Dit codevoorbeeld is voor de duidelijkheid enigszins vereenvoudigd, en ik zal hetzelfde doen met veel van de andere postvoorbeelden. De volledige code kunt u altijd bekijken op GitHub.

We hebben:

  • HTML5-canvaselement (<canvas>), die we zullen gebruiken om het spel te renderen.
  • <link> om ons CSS-pakket toe te voegen.
  • <script> om ons Javascript-pakket toe te voegen.
  • Hoofdmenu met gebruikersnaam <input> en de “PLAY”-knop (<button>).

Zodra de startpagina is geladen, begint de browser met het uitvoeren van Javascript-code, te beginnen met het JS-bestand van het toegangspunt: 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);
  };
});

Dit klinkt misschien ingewikkeld, maar er is hier niet veel aan de hand:

  1. Importeren van verschillende andere JS-bestanden.
  2. CSS importeren (zodat Webpack ze weet op te nemen in ons CSS-pakket).
  3. lancering connect() om een ​​verbinding met de server tot stand te brengen en te starten downloadAssets() om afbeeldingen te downloaden die nodig zijn om het spel weer te geven.
  4. Na voltooiing van fase 3 hoofdmenu wordt weergegeven (playMenu).
  5. De handler instellen voor het indrukken van de knop "PLAY". Wanneer de knop wordt ingedrukt, initialiseert de code het spel en vertelt de server dat we klaar zijn om te spelen.

Het belangrijkste deel van onze client-serverlogica zit in de bestanden die door het bestand zijn geïmporteerd index.js. Nu zullen we ze allemaal in volgorde beschouwen.

4. Uitwisseling van klantgegevens

In dit spel gebruiken we een bekende bibliotheek om met de server te communiceren socket.io:. Socket.io heeft native ondersteuning WebSockets, die zeer geschikt zijn voor tweerichtingscommunicatie: we kunnen berichten naar de server sturen и de server kan via dezelfde verbinding berichten naar ons sturen.

We zullen één bestand hebben src/client/networking.jsvoor wie zal zorgen bij alle communicatie met de server:

netwerken.js

import io from 'socket.io-client';
import { processGameUpdate } from './state';

const Constants = require('../shared/constants');

const socket = io(`ws://${window.location.host}`);
const connectedPromise = new Promise(resolve => {
  socket.on('connect', () => {
    console.log('Connected to server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Register 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);
};

Voor de duidelijkheid is deze code ook iets ingekort.

Er zijn drie hoofdacties in dit bestand:

  • We proberen verbinding te maken met de server. connectedPromise alleen toegestaan ​​als wij een verbinding tot stand hebben gebracht.
  • Als de verbinding succesvol is, registreren we terugbelfuncties (processGameUpdate() и onGameOver()) voor berichten die we van de server kunnen ontvangen.
  • Wij exporteren play() и updateDirection()zodat andere bestanden ze kunnen gebruiken.

5. Klantweergave

Het is tijd om de afbeelding op het scherm weer te geven!

…maar voordat we dat kunnen doen, moeten we alle afbeeldingen (bronnen) downloaden die hiervoor nodig zijn. Laten we een resourcemanager schrijven:

activa.js

const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];

Resource management is niet zo moeilijk te implementeren! Het belangrijkste punt is het opslaan van een object assets, waarmee de sleutel van de bestandsnaam aan de waarde van het object wordt gebonden Image. Wanneer de bron wordt geladen, slaan we deze op in een object assets voor snelle toegang in de toekomst. Wanneer is het downloaden van elke individuele bron toegestaan ​​(dat wil zeggen, zal download alle middelen), staan ​​wij toe downloadPromise.

Nadat u de bronnen heeft gedownload, kunt u beginnen met renderen. Zoals eerder gezegd, gebruiken we om op een webpagina te tekenen HTML5-canvas (<canvas>). Ons spel is vrij eenvoudig, dus we hoeven alleen het volgende weer te geven:

  1. achtergrond
  2. Speler schip
  3. Andere spelers in het spel
  4. Schelpen

Hier zijn de belangrijke fragmenten src/client/render.js, die precies de vier hierboven genoemde items 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;

// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

function render() {
  const { me, others, bullets } = getCurrentState();
  if (!me) {
    return;
  }

  // Draw background
  renderBackground(me.x, me.y);

  // Draw all bullets
  bullets.forEach(renderBullet.bind(null, me));

  // Draw all players
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Helper functions here excluded

let renderInterval = null;
export function startRendering() {
  renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
  clearInterval(renderInterval);
}

Voor de duidelijkheid is deze code ook ingekort.

render() is de hoofdfunctie van dit bestand. startRendering() и stopRendering() controleer de activering van de renderlus op 60 FPS.

Specifieke implementaties van individuele rendering-helperfuncties (bijvoorbeeld renderBullet()) zijn niet zo belangrijk, maar hier is een eenvoudig 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,
  );
}

Merk op dat we de methode gebruiken getAsset(), die eerder te zien was in asset.js!

Als je geïnteresseerd bent in het verkennen van andere functies voor weergavehulp, lees dan de rest van src/client/render.js.

6. Inbreng van de klant

Het is tijd om een ​​spel te maken speelbaar! Het besturingsschema zal heel eenvoudig zijn: om de bewegingsrichting te veranderen, kunt u de muis gebruiken (op een computer) of het scherm aanraken (op een mobiel apparaat). Om dit te realiseren zullen wij ons registreren Gebeurtenisluisteraars voor muis- en aanraakgebeurtenissen.
Zal dit allemaal verzorgen src/client/input.js:

invoer.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() и onTouchInput() zijn gebeurtenislisteners die bellen updateDirection() (uit networking.js) wanneer een invoergebeurtenis plaatsvindt (bijvoorbeeld wanneer de muis wordt verplaatst). updateDirection() verwerkt de berichtenuitwisseling met de server, die de invoergebeurtenis afhandelt en de spelstatus dienovereenkomstig bijwerkt.

7. Klantstatus

Dit gedeelte is het moeilijkste in het eerste deel van het bericht. Wees niet ontmoedigd als u het de eerste keer dat u het leest, niet begrijpt! Je kunt het zelfs overslaan en er later op terugkomen.

Het laatste stukje van de puzzel dat nodig is om de client/server-code te voltooien is staat. Kent u het codefragment uit de sectie Clientweergave nog?

render.js

import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Do the rendering
  // ...
}

getCurrentState() zou ons de huidige stand van zaken in de client moeten kunnen geven op elk moment gebaseerd op updates ontvangen van de server. Hier is een voorbeeld van een game-update 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": "Player",
      "score": 3
    }
  ]
}

Elke game-update bevat vijf identieke velden:

  • t: Servertijdstempel die aangeeft wanneer deze update is gemaakt.
  • me: Informatie over de speler die deze update ontvangt.
  • anderen: Een reeks informatie over andere spelers die aan hetzelfde spel deelnemen.
  • kogels: een scala aan informatie over projectielen in het spel.
  • leaderboard: Huidige klassementgegevens. In dit bericht zullen we ze niet overwegen.

7.1 Naïeve cliëntstatus

Naïeve implementatie getCurrentState() kan alleen rechtstreeks de gegevens van de meest recent ontvangen game-update retourneren.

naïef-state.js

let lastGameUpdate = null;

// Handle a newly received game update.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Lekker duidelijk! Maar was het maar zo simpel. Een van de redenen waarom deze implementatie problematisch is: het beperkt de weergaveframesnelheid tot de kloksnelheid van de server.

Frame rate: aantal frames (d.w.z. oproepen render()) per seconde of FPS. Games streven er doorgaans naar om minimaal 60 FPS te behalen.

Vink Tarief aan: de frequentie waarmee de server game-updates naar clients verzendt. Deze is vaak lager dan de framesnelheid. In ons spel draait de server op 30 ticks per seconde.

Als we alleen de nieuwste game-update renderen, zal de FPS in wezen nooit boven de 30 kunnen komen we ontvangen nooit meer dan 30 updates per seconde van de server. Zelfs als we bellen render() 60 keer per seconde, dan zal de helft van deze oproepen eenvoudigweg hetzelfde opnieuw tekenen en in wezen niets doen. Een ander probleem met de naïeve implementatie is dat het gevoelig voor vertragingen. Bij ideale internetsnelheid ontvangt de client precies elke 33 ms (30 per seconde) een game-update:

Een multiplayer-webgame maken in het .io-genre
Helaas is niets perfect. Een realistischer beeld zou zijn:
Een multiplayer-webgame maken in het .io-genre
Een naïeve implementatie is vrijwel het slechtste geval als het gaat om latentie. Als een game-update met een vertraging van 50 ms wordt ontvangen, dan cliënt kraampjes een extra 50 ms omdat de gamestatus van de vorige update nog steeds wordt weergegeven. Je kunt je voorstellen hoe ongemakkelijk dit is voor de speler: willekeurig remmen zorgt ervoor dat het spel schokkerig en onstabiel aanvoelt.

7.2 Verbeterde klantstatus

We zullen enkele verbeteringen aanbrengen in de naïeve implementatie. Ten eerste gebruiken wij weergave vertraging met 100 ms. Dit betekent dat de "huidige" status van de client altijd 100 ms achterloopt op de spelstatus op de server. Als de servertijd bijvoorbeeld 150, dan geeft de client de staat weer waarin de server zich op dat moment bevond 50:

Een multiplayer-webgame maken in het .io-genre
Dit geeft ons een buffer van 100 ms om onvoorspelbare game-updatetijden te overleven:

Een multiplayer-webgame maken in het .io-genre
De uitbetaling hiervoor zal permanent zijn invoervertraging gedurende 100 ms. Dit is een kleine opoffering voor een soepele gameplay; de meeste spelers (vooral gewone spelers) zullen deze vertraging niet eens merken. Het is veel gemakkelijker voor mensen om zich aan te passen aan een constante latentie van 100 ms dan om te spelen met onvoorspelbare latentie.

We kunnen ook een andere techniek gebruiken, genaamd Voorspelling aan de klantzijde, dat de waargenomen latentie goed vermindert, maar in dit bericht niet wordt behandeld.

Een andere verbetering die we gebruiken is lineaire interpolatie. Vanwege weergavevertraging lopen we doorgaans minimaal één update voor op de huidige tijd in de client. Wanneer gebeld getCurrentState(), wij kunnen vervullen lineaire interpolatie tussen game-updates net voor en na de huidige tijd in de client:

Een multiplayer-webgame maken in het .io-genre
Dit lost het probleem met de framesnelheid op: we kunnen nu unieke frames weergeven met elke gewenste framesnelheid!

7.3 Implementeren van een verbeterde klantstatus

Voorbeeld implementatie in src/client/state.js gebruikt zowel rendervertraging als lineaire interpolatie, maar niet voor lang. Laten we de code in twee delen opsplitsen. Hier is de eerste:

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);

  // 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;
}

De eerste stap is om erachter te komen wat currentServerTime(). Zoals we eerder zagen, bevat elke game-update een servertijdstempel. We willen renderlatentie gebruiken om de afbeelding 100 ms achter de server weer te geven, maar we zullen nooit de huidige tijd op de server weten, omdat we niet kunnen weten hoe lang het duurde voordat een van de updates ons bereikte. Het internet is onvoorspelbaar en de snelheid kan enorm variëren!

Om dit probleem te omzeilen, kunnen we een redelijke benadering gebruiken: wij laten we doen alsof de eerste update onmiddellijk arriveerde. Als dit waar zou zijn, zouden we de servertijd op dat specifieke moment weten! We slaan de tijdstempel van de server op firstServerTimestamp en houd onze lokaal (klant) tijdstempel op hetzelfde moment in gameStart.

Oh wacht. Moet het niet servertijd = clienttijd zijn? Waarom maken we onderscheid tussen "servertijdstempel" en "clienttijdstempel"? Dit is een geweldige vraag! Het blijkt dat ze niet hetzelfde zijn. Date.now() zal verschillende tijdstempels retourneren in de client en server en dit hangt af van factoren die lokaal zijn voor deze machines. Ga er nooit vanuit dat tijdstempels op alle machines hetzelfde zullen zijn.

Nu begrijpen we wat dat doet currentServerTime(): het keert terug de servertijdstempel van de huidige weergavetijd. Met andere woorden, dit is de huidige tijd van de server (firstServerTimestamp <+ (Date.now() - gameStart)) minus weergavevertraging (RENDER_DELAY).

Laten we nu eens kijken hoe we omgaan met game-updates. Wanneer er een update van de server wordt ontvangen, wordt deze aangeroepen processGameUpdate()en we slaan de nieuwe update op in een array gameUpdates. Om het geheugengebruik te controleren, verwijderen we vervolgens alle oude updates basis-updateomdat we ze niet meer nodig hebben.

Wat is een "basisupdate"? Dit de eerste update vinden we door terug te gaan vanaf de huidige servertijd. Onthoud dit diagram?

Een multiplayer-webgame maken in het .io-genre
De game-update direct links van "Client Render Time" is de basisupdate.

Waar wordt de basisupdate voor gebruikt? Waarom kunnen we updates naar de basis droppen? Om dit te begrijpen, laten we eindelijk laten we eens kijken naar de implementatie 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),
    };
  }
}

Wij behandelen drie gevallen:

  1. base < 0 betekent dat er geen updates zijn tot de huidige weergavetijd (zie implementatie hierboven). getBaseUpdate()). Dit kan al aan het begin van het spel gebeuren vanwege weergavevertraging. In dit geval gebruiken we de meest recent ontvangen update.
  2. base is de laatste update die we hebben. Dit kan te wijten zijn aan netwerkvertraging of een slechte internetverbinding. In dit geval gebruiken we ook de nieuwste update die we hebben.
  3. We hebben zowel voor als na de huidige rendertijd een update, dus dat kan interpoleren!

Alles wat er nog in zit state.js is een implementatie van lineaire interpolatie die eenvoudige (maar saaie) wiskunde is. Als je het zelf wilt verkennen, open dan state.js op GitHub.

Deel 2. Backend-server

In dit deel bekijken we de Node.js-backend die onze .io-spelvoorbeeld.

1. Serveringangspunt

Om de webserver te beheren zullen we een populair webframework voor Node.js gebruiken, genaamd Uitdrukken. Het wordt geconfigureerd door ons serveringangspuntbestand 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 an Express server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Setup Webpack for development
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Static serve the dist/ folder in production
  app.use(express.static('dist'));
}

// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

Weet je nog dat we in het eerste deel Webpack bespraken? Dit is waar we onze Webpack-configuraties zullen gebruiken. We zullen ze op twee manieren gebruiken:

  • Gebruiken webpack-dev-middleware om onze ontwikkelingspakketten automatisch opnieuw op te bouwen, of
  • map statisch overbrengen dist/, waarin Webpack onze bestanden zal schrijven na de productiebuild.

Nog een belangrijke taak server.js is om de server in te stellen socket.io:die gewoon 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 listening on port ${port}`);

// Setup socket.io
const io = socketio(server);

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

Nadat we met succes een socket.io-verbinding met de server tot stand hebben gebracht, hebben we gebeurtenishandlers voor de nieuwe socket ingesteld. Gebeurtenishandlers verwerken berichten die zijn ontvangen van clients door te delegeren aan een singleton-object game:

server.js deel 3

const Game = require('./game');

// ...

// Setup the Game
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

We maken een .io-game, dus we hebben maar één exemplaar nodig Game ("Game") - alle spelers spelen in dezelfde arena! In de volgende sectie zullen we zien hoe deze klasse werkt. Game.

2. Spelservers

Klasse Game bevat de belangrijkste server-side logica. Het heeft twee hoofdtaken: spelersbeheer и spelsimulatie.

Laten we beginnen met de eerste taak, spelersbeheer.

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;

    // Generate a position to start this player at.
    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 identificeren we de spelers op basis van het veld id hun socket.io socket (als je in de war raakt, ga dan terug naar server.js). Socket.io kent zelf elke socket een unieke waarde toe id, dus daar hoeven we ons geen zorgen over te maken. ik zal hem bellen Speler-ID.

Laten we, met dat in gedachten, de instantievariabelen in een klasse onderzoeken Game:

  • sockets is een object dat de speler-ID bindt aan de socket die aan de speler is gekoppeld. Het stelt ons in staat om in de loop van de tijd toegang te krijgen tot sockets op basis van hun speler-ID's.
  • players is een object dat de speler-ID bindt aan de code>Spelerobject

bullets is een array van objecten Bullet, die geen duidelijke volgorde heeft.
lastUpdateTime is de tijdstempel van de laatste keer dat de game is bijgewerkt. We zullen binnenkort zien hoe het wordt gebruikt.
shouldSendUpdate is een hulpvariabele. We zullen het gebruik ervan binnenkort ook zien.
methoden addPlayer(), removePlayer() и handleInput() geen uitleg nodig, ze worden gebruikt in server.js. Als je je geheugen wilt opfrissen, ga dan iets hoger terug.

Laatste lijn constructor() start op update cyclus games (met een frequentie van 60 updates/s):

game.js deel 2

const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // Calculate time elapsed
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update each bullet
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    // Update each player
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    // Apply collisions, give players score for hitting bullets
    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),
    );

    // Check if any players are dead
    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);
      }
    });

    // Send a game update to each player every other time
    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;
    }
  }

  // ...
}

werkwijze update() bevat misschien wel het belangrijkste stukje server-side logica. Dit is wat het doet, in volgorde:

  1. Berekent hoe laat het is dt sinds de laatste voorbij update().
  2. Vernieuwt elk projectiel en vernietigt ze indien nodig. De implementatie van deze functionaliteit zullen we later zien. Voor nu is het voor ons voldoende om dat te weten bullet.update() geeft terug true, als het projectiel moet worden vernietigd (hij ging buiten de arena).
  3. Update elke speler en laat indien nodig een projectiel verschijnen. Deze implementatie zullen we later ook zien - player.update() kan een voorwerp retourneren Bullet.
  4. Controleert op botsingen tussen projectielen en spelers met applyCollisions(), die een reeks projectielen retourneert die spelers raken. Voor elk teruggestuurd projectiel verhogen we de punten van de speler die het heeft afgevuurd (met behulp van player.onDealtDamage()) en verwijder vervolgens het projectiel uit de array bullets.
  5. Waarschuwt en vernietigt alle gedode spelers.
  6. Stuurt een game-update naar alle spelers elke seconde keer gebeld update(). De hierboven genoemde hulpvariabele helpt ons dit bij te houden shouldSendUpdate... Net zo update() 60 keer per seconde gebeld, we sturen game-updates 30 keer per seconde. Dus, klok frequentie server is 30 klokcycli/s (we hebben het in het eerste deel gehad over de klokfrequentie).

Waarom alleen game-updates sturen? door de tijd ? Kanaal opslaan. 30 game-updates per seconde is veel!

Waarom niet gewoon bellen update() 30 keer per seconde? Om de spelsimulatie te verbeteren. Hoe vaker gebeld update(), hoe nauwkeuriger de spelsimulatie zal zijn. Maar laat je niet te veel meeslepen door het aantal uitdagingen update(), omdat dit een rekentechnisch dure taak is - 60 per seconde is voldoende.

De rest van de klas Game bestaat uit helpermethoden die worden gebruikt 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) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() Het is vrij eenvoudig: het sorteert spelers op score, neemt de top vijf en retourneert voor elke speler de gebruikersnaam en score.

createUpdate() gebruikt in update() om game-updates te maken die onder spelers worden gedistribueerd. De belangrijkste taak is het aanroepen van methoden serializeForUpdate(), geïmplementeerd voor klassen Player и Bullet. Merk op dat het alleen gegevens doorgeeft aan elke speler dichtstbijzijnde spelers en projectielen - het is niet nodig om informatie te verzenden over spelobjecten die zich ver van de speler bevinden!

3. Spelobjecten op de server

In ons spel lijken projectielen en spelers eigenlijk heel veel op elkaar: het zijn abstracte, ronde, beweegbare spelobjecten. Om te profiteren van deze gelijkenis tussen spelers en projectielen, beginnen we met het implementeren van de basisklasse 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,
    };
  }
}

Er is hier niets ingewikkelds aan de hand. Deze klasse zal een goed startpunt zijn voor uitbreiding. Laten we eens kijken hoe de klas Bullet toepassingen 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;
  }

  // Returns true if the bullet should be destroyed
  update(dt) {
    super.update(dt);
    return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

uitvoering Bullet heel kort! Wij hebben toegevoegd aan Object alleen de volgende extensies:

  • Het pakket gebruiken kortom voor willekeurige generatie id projectiel.
  • Een veld toevoegen parentIDzodat je de speler kunt volgen die dit projectiel heeft gemaakt.
  • De retourwaarde toevoegen aan update(), wat gelijk is trueals het projectiel zich buiten de arena bevindt (weet je nog dat we hierover in de vorige sectie spraken?).

Laten we verder gaan naar Player:

speler.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,
    };
  }
}

Spelers zijn complexer dan projectielen, dus er moeten nog een paar velden in deze klasse worden opgeslagen. Zijn methode update() doet vooral veel werk en retourneert het nieuw gemaakte projectiel als er geen meer over zijn fireCooldown (Weet je nog dat we hierover in de vorige sectie spraken?). Het breidt ook de methode uit serializeForUpdate(), omdat we extra velden voor de speler moeten opnemen in de game-update.

Het hebben van een basisklasse Object - een belangrijke stap om herhaling van code te voorkomen. Geen les bijvoorbeeld Object elk spelobject moet dezelfde implementatie hebben distanceTo(), en het kopiëren en plakken van al deze implementaties in meerdere bestanden zou een nachtmerrie zijn. Dit wordt vooral belangrijk bij grote projecten., wanneer het aantal uitbreidt Object klassen groeien.

4. Botsingsdetectie

Het enige wat wij nog moeten doen is herkennen wanneer de projectielen de spelers raken! Onthoud dit stukje code uit de methode update() in de klas Game:

game.js

const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // ...

    // Apply collisions, give players score for hitting bullets
    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 de methode implementeren applyCollisions(), die alle projectielen retourneert die spelers raken. Gelukkig is het niet zo moeilijk om te doen, want

  • Alle botsende objecten zijn cirkels, wat de eenvoudigste vorm is om botsingsdetectie te implementeren.
  • We hebben al een methode distanceTo(), die we in de vorige sectie in de klas hebben geïmplementeerd Object.

Zo ziet onze implementatie van botsingsdetectie eruit:

botsingen.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 botsingsdetectie is gebaseerd op het feit dat twee cirkels botsen als de afstand tussen hun middelpunten kleiner is dan de som van hun stralen. Hier is het geval waarin de afstand tussen de middelpunten van twee cirkels precies gelijk is aan de som van hun stralen:

Een multiplayer-webgame maken in het .io-genre
Er zijn nog een paar aspecten waarmee u rekening moet houden:

  • Het projectiel mag de speler die het heeft gemaakt niet raken. Dit kan worden bereikt door te vergelijken bullet.parentID с player.id.
  • Het projectiel mag slechts één keer raken in het beperkende geval dat meerdere spelers tegelijkertijd botsen. We zullen dit probleem oplossen met behulp van de operator break: zodra de speler die in botsing komt met het projectiel wordt gevonden, stoppen we de zoektocht en gaan we door naar het volgende projectiel.

uiteinde

Dat is alles! We hebben alles besproken wat u moet weten om een ​​.io-webgame te maken. Wat is het volgende? Bouw je eigen .io-spel!

Alle voorbeeldcode is open source en gepost op GitHub.

Bron: www.habr.com

Voeg een reactie