Opprette et flerspillernettspill i .io-sjangeren

Opprette et flerspillernettspill i .io-sjangeren
Utgitt i 2015 Agar.io ble stamfader til en ny sjanger games.io, hvis popularitet har vokst kraftig siden den gang. Jeg har opplevd økningen i popularitet til .io-spill selv: I løpet av de siste tre årene har jeg laget og solgte to spill i denne sjangeren..

I tilfelle du aldri har hørt om disse spillene før, er de gratis flerspillernettspill som er enkle å spille (ingen konto kreves). De setter vanligvis mange motstandere på én arena. Andre kjente .io-spill: Slither.io и Diep.io.

I dette innlegget skal vi finne ut hvordan lag et .io-spill fra bunnen av. For å gjøre dette vil bare kunnskap om Javascript være nok: du må forstå ting som syntaks ES6, nøkkelord this и Promises. Selv om du ikke kan Javascript perfekt, kan du fortsatt forstå det meste av innlegget.

Eksempel på et .io-spill

For opplæringshjelp vil vi henvise til eksempel spill .io. Prøv å spille det!

Opprette et flerspillernettspill i .io-sjangeren
Spillet er ganske enkelt: du styrer et skip på en arena sammen med andre spillere. Skipet ditt avfyrer automatisk prosjektiler og du prøver å treffe andre spillere mens du unngår prosjektilene deres.

1. Kort oversikt/prosjektstruktur

anbefale last ned kildekoden eksempel spill slik at du kan følge meg.

Eksemplet bruker følgende:

  • Uttrykke er det mest populære nettrammeverket for Node.js som administrerer spillets webserver.
  • stikkontakt.io — websocket-bibliotek for utveksling av data mellom nettleseren og serveren.
  • Webpack - modulansvarlig. Du kan lese om hvorfor du bruker Webpack her.

Slik ser prosjektkatalogstrukturen ut:

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

offentlig/

Alt ligger i mappen public/ vil bli statisk overført av serveren. I public/assets/ inneholder bilder brukt av prosjektet vårt.

src /

All kildekode er i mappen src/. Titler client/ и server/ snakke for seg selv og shared/ inneholder en konstantfil importert av både klienten og serveren.

2. Sammenstillinger/prosjektparametere

Som nevnt ovenfor bruker vi en modulansvarlig for å bygge prosjektet Webpack. La oss ta en titt på vår Webpack-konfigurasjon:

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 viktigste linjene her er følgende:

  • src/client/index.js er inngangspunktet til Javascript (JS) klienten. Webpack vil starte herfra og lete rekursivt etter andre importerte filer.
  • Utdata-JS fra vår Webpack-bygg vil bli plassert i katalogen dist/. Jeg kaller denne filen vår JS-pakke.
  • Vi bruker Babel, og spesielt konfigurasjonen @babel/preset-env å transpilere vår JS-kode for eldre nettlesere.
  • Vi bruker en plugin for å trekke ut all CSS referert til av JS-filer og kombinere dem på ett sted. Jeg vil kalle den vår CSS-pakke.

Du har kanskje lagt merke til merkelige pakkefilnavn '[name].[contenthash].ext'. De inneholder filnavnerstatning Nettpakke: [name] vil bli erstattet med navnet på inngangspunktet (i vårt tilfelle er det game), a [contenthash] vil bli erstattet med en hash av filinnholdet. Vi gjør dette for å optimalisere prosjektet for hashing - vi kan fortelle nettlesere å cache JS-pakkene våre på ubestemt tid fordi hvis en pakke endres, endres også filnavnet (Endringer contenthash). Det ferdige resultatet vil være filnavnet på visningen game.dbeee76e91a97d0c7207.js.

fil webpack.common.js – Dette er basiskonfigurasjonsfilen som vi importerer inn i utviklingen og ferdige prosjektkonfigurasjoner. Her er for eksempel utviklingskonfigurasjonen:

webpack.dev.js

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

For effektivitet bruker vi i utviklingsprosessen webpack.dev.js, og bytter til webpack.prod.js, for å optimalisere pakkestørrelser når de distribueres til produksjon.

Lokalt oppsett

Jeg anbefaler å installere prosjektet på din lokale maskin slik at du kan følge trinnene som er oppført i dette innlegget. Oppsettet er enkelt: først må systemet ha Node и NPM. Neste må du gjøre

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

og du er klar til å gå! For å starte utviklingsserveren, bare kjør

$ npm run develop

og gå til nettleseren din localhost: 3000. Utviklingsserveren vil automatisk gjenoppbygge JS- og CSS-pakkene etter hvert som kodeendringer skjer - bare oppdater siden for å se alle endringene!

3. Klientens inngangspunkter

La oss komme ned til selve spillkoden. Først trenger vi en side index.html, når du besøker nettstedet, vil nettleseren laste det først. Siden vår vil være ganske enkel:

index.html

Et eksempel på .io-spill  SPILLE

Dette kodeeksemplet har blitt litt forenklet for klarhetens skyld, og jeg vil gjøre det samme med mange av de andre eksemplene i innlegget. Du kan alltid se på hele koden på Github.

Vi har:

  • HTML5 Canvas-element (<canvas>), som vi vil bruke til å gjengi spillet.
  • <link> for å legge til vår CSS-pakke.
  • <script> for å legge til vår Javascript-pakke.
  • Hovedmeny med brukernavn <input> og "PLAY"-knappen (<button>).

Når hjemmesiden er lastet, vil nettleseren begynne å kjøre Javascript-kode, og starter med inngangspunktet JS-filen: 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);
  };
});

Dette høres kanskje komplisert ut, men det er faktisk ikke mye som skjer her:

  1. Importer flere andre JS-filer.
  2. Importer CSS (slik at Webpack vet å inkludere dem i CSS-pakken vår).
  3. lanseringen connect() for å opprette en tilkobling til serveren og starte downloadAssets() for å laste ned bildene som trengs for å gjengi spillet.
  4. Etter å ha fullført trinn 3 hovedmenyen vises (playMenu).
  5. Sette opp "PLAY"-knappen klikkbehandler. Når knappen trykkes, initialiserer koden spillet og forteller serveren at vi er klare til å spille.

Det viktigste "kjøttet" til klient-server-logikken vår er i de filene som ble importert av filen index.js. Nå skal vi se på dem alle i rekkefølge.

4. Utveksling av klientdata

I dette spillet bruker vi et velkjent bibliotek for å kommunisere med serveren stikkontakt.io. Socket.io har innebygd støtte WebSockets, som er godt egnet for toveiskommunikasjon: vi kan sende meldinger til serveren и serveren kan sende meldinger til oss over samme tilkobling.

Vi vil ha én fil src/client/networking.jshvem skal ta seg av av alle kommunikasjon med serveren:

networking.js

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

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

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

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

Denne koden er også litt forkortet for klarhetens skyld.

Det er tre hovedting som skjer i denne filen:

  • Vi prøver å koble til serveren. connectedPromise kun tillatt når vi har opprettet en forbindelse.
  • Hvis tilkoblingen er vellykket, registrerer vi tilbakeringingsfunksjoner (processGameUpdate() и onGameOver()) for meldinger som vi kan motta fra serveren.
  • Vi eksporterer play() и updateDirection()slik at andre filer kan bruke dem.

5. Klientgjengivelse

Det er på tide å vise bildet på skjermen!

...men før vi kan gjøre dette, må vi laste ned alle bildene (ressursene) som trengs for dette. La oss skrive en ressursansvarlig:

assets.js

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

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

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

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

Ressursstyring er ikke så vanskelig å implementere! Hovedpoenget er å lagre en gjenstand assets, som vil binde filnavnnøkkelen til objektverdien Image. Når ressursen er lastet, lagrer vi den til et objekt assets for rask mottak i fremtiden. Når vil nedlasting av hver enkelt ressurs være tillatt (det vil si nedlasting alle ressurser), tillater vi downloadPromise.

Etter å ha lastet ned ressursene, kan du begynne å gjengi. Som sagt tidligere, for å tegne på en nettside vi bruker HTML5 lerret (<canvas>). Spillet vårt er ganske enkelt, så vi trenger bare å gjengi følgende:

  1. bakgrunn
  2. Spillerskip
  3. Andre spillere i spillet
  4. Skjell

Her er de viktige utdragene src/client/render.js, som trekker nøyaktig de fire punktene som er oppført ovenfor:

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

Denne koden er også forkortet for klarhetens skyld.

render() er hovedfunksjonen til denne filen. startRendering() и stopRendering() kontroller aktiveringen av gjengivelsessyklusen ved 60 FPS.

Spesifikke implementeringer av individuelle gjengivelseshjelpefunksjoner (for eksempel renderBullet()) er ikke så viktige, men her er ett enkelt eksempel:

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 at vi bruker metoden getAsset(), som tidligere ble sett i asset.js!

Hvis du er interessert i å utforske andre gjengivelseshjelpefunksjoner, så les resten av src/client/render.js.

6. Klientinnspill

Det er på tide å lage et spill spillbare! Kontrollskjemaet vil være veldig enkelt: for å endre bevegelsesretningen kan du bruke musen (på en datamaskin) eller berøre skjermen (på en mobilenhet). For å implementere dette vil vi registrere oss Eventlyttere for Mouse and Touch-hendelser.
Skal ta seg av alt dette 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() и onTouchInput() er hendelseslyttere som ringer updateDirection() (av networking.js) når en inndatahendelse oppstår (for eksempel når musen flyttes). updateDirection() omhandler utveksling av meldinger med serveren, som behandler inndatahendelsen og oppdaterer spilltilstanden deretter.

7. Klientstatus

Denne delen er den vanskeligste i første del av innlegget. Ikke bli motløs hvis du ikke forstår det første gang du leser det! Du kan til og med hoppe over det og komme tilbake til det senere.

Den siste brikken i puslespillet som trengs for å fullføre klient-serverkoden er stat. Husker du kodebiten fra Client Rendering-delen?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() skal kunne gi oss gjeldende spillstatus i klienten når som helst basert på oppdateringer mottatt fra serveren. Her er et eksempel på en spilloppdatering som serveren kan sende:

{
  "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
    }
  ]
}

Hver spilloppdatering inneholder fem identiske felt:

  • t: Servertidsstempel som indikerer når denne oppdateringen ble opprettet.
  • me: Informasjon om spilleren som mottar denne oppdateringen.
  • andre: En rekke informasjon om andre spillere som deltar i det samme spillet.
  • kuler: rekke informasjon om prosjektiler i spillet.
  • leaderboard: Gjeldende resultattavledata. Vi vil ikke ta hensyn til dem i dette innlegget.

7.1 Klientens naive tilstand

Naiv implementering getCurrentState() kan bare returnere data direkte fra den sist mottatte spilloppdateringen.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Vakkert og oversiktlig! Men hvis det bare var så enkelt. En av grunnene til at denne implementeringen er problematisk: det begrenser gjengivelsesbildehastigheten til serverklokkehastigheten.

Bildefrekvens: antall rammer (dvs. anrop render()) per sekund, eller FPS. Spill streber vanligvis etter å oppnå minst 60 FPS.

Kryss av for Rate: Frekvensen serveren sender spilloppdateringer med til klienter. Det er ofte lavere enn bildefrekvensen. I spillet vårt kjører serveren med 30 ticks per sekund.

Hvis vi bare gjengir den siste spilloppdateringen, vil FPS i hovedsak aldri kunne overstige 30 fordi vi mottar aldri mer enn 30 oppdateringer per sekund fra serveren. Selv om vi ringer render() 60 ganger i sekundet, vil halvparten av disse samtalene ganske enkelt tegne det samme på nytt, og i hovedsak gjøre ingenting. Et annet problem med en naiv implementering er at den med forbehold om forsinkelser. Ved ideell internetthastighet vil klienten motta en spilloppdatering nøyaktig hver 33 ms (30 per sekund):

Opprette et flerspillernettspill i .io-sjangeren
Dessverre er ingenting perfekt. Et mer realistisk bilde vil være:
Opprette et flerspillernettspill i .io-sjangeren
En naiv implementering er stort sett det verste tilfellet når det kommer til latens. Hvis en spilloppdatering mottas med en forsinkelse på 50 ms, da klienten bremses med 50 ms ekstra fordi den fortsatt gjengir spilltilstanden fra forrige oppdatering. Du kan forestille deg hvor upraktisk dette er for spilleren: på grunn av vilkårlige nedganger vil spillet virke rykkete og ustabilt.

7.2 Forbedret klienttilstand

Vi vil gjøre noen forbedringer i den naive implementeringen. For det første bruker vi gjengivelsesforsinkelse med 100 ms. Dette betyr at den "nåværende" tilstanden til klienten alltid vil være 100ms bak spilltilstanden på serveren. For eksempel hvis servertiden er 150, så vil klienten gjengi tilstanden serveren var i på det tidspunktet 50:

Opprette et flerspillernettspill i .io-sjangeren
Dette gir oss en 100 ms buffer for å overleve den uforutsigbare timingen av spilloppdateringer:

Opprette et flerspillernettspill i .io-sjangeren
Prisen for dette vil være permanent input lag med 100 ms. Dette er et lite offer for jevn spilling - de fleste spillere (spesielt uformelle) vil ikke engang merke denne forsinkelsen. Det er mye lettere for folk å tilpasse seg en konstant 100 ms latency enn å spille med uforutsigbar ventetid.

Vi kan bruke en annen teknikk kalt "prognose på klientsiden", som gjør en god jobb med å redusere opplevd latens, men som ikke vil bli diskutert i dette innlegget.

En annen forbedring vi bruker er Lineær interpolering. På grunn av gjengivelsesforsinkelse ligger vi vanligvis minst én oppdatering foran gjeldende tid i klienten. Når du ringer getCurrentState(), kan vi oppfylle Lineær interpolering mellom spilloppdateringer rett før og etter gjeldende tid i klienten:

Opprette et flerspillernettspill i .io-sjangeren
Dette løser bildefrekvensproblemet: vi kan nå gjengi unike rammer med hvilken som helst bildefrekvens vi trenger!

7.3 Implementere en forbedret klienttilstand

Eksempelimplementering i src/client/state.js bruker både gjengivelsesforsinkelse og lineær interpolasjon, men dette varer ikke lenge. La oss dele koden i to deler. Her er den første:

state.js, del 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;
}

Det første du må gjøre er å finne ut hva det gjør currentServerTime(). Som vi så tidligere, inkluderer hver spilloppdatering et servertidsstempel. Vi ønsker å bruke gjengivelsesforsinkelse for å gjengi bildet 100 ms bak serveren, men vi vil aldri vite gjeldende tid på serveren, fordi vi ikke kan vite hvor lang tid det tok før noen av oppdateringene nådde oss. Internett er uforutsigbart og hastigheten kan variere mye!

For å omgå dette problemet kan vi bruke en rimelig tilnærming: vi la oss late som om den første oppdateringen kom umiddelbart. Hvis dette var sant, ville vi vite servertiden i det aktuelle øyeblikket! Vi lagrer servertidsstemplet i firstServerTimestamp og redde vår lokal (klient) tidsstempel i samme øyeblikk i gameStart.

Å, vent litt. Skal det ikke være tid på serveren = tid på klienten? Hvorfor skiller vi mellom "servertidsstempel" og "klienttidsstempel"? Dette er et flott spørsmål! Det viser seg at disse ikke er det samme. Date.now() vil returnere forskjellige tidsstempler i klienten og serveren, og dette avhenger av faktorer som er lokale for disse maskinene. Anta aldri at tidsstemplene vil være like på alle maskiner.

Nå forstår vi hva det gjør currentServerTime(): den kommer tilbake servertidsstempel for gjeldende gjengivelsestid. Med andre ord, dette er gjeldende servertid (firstServerTimestamp <+ (Date.now() - gameStart)) minus gjengivelsesforsinkelse (RENDER_DELAY).

La oss nå se på hvordan vi håndterer spilloppdateringer. Når en oppdatering mottas fra serveren, kalles den opp processGameUpdate(), og vi lagrer den nye oppdateringen i en matrise gameUpdates. Deretter, for å sjekke minnebruk, fjerner vi alle gamle oppdateringer til basisoppdateringfordi vi ikke trenger dem lenger.

Hva er en "kjerneoppdatering"? Dette den første oppdateringen finner vi ved å gå bakover fra gjeldende servertid. Husker du dette diagrammet?

Opprette et flerspillernettspill i .io-sjangeren
Spilloppdateringen rett til venstre for "Client Render Time" er basisoppdateringen.

Hva brukes basisoppdateringen til? Hvorfor kan vi slippe oppdateringer til basen? For å forstå dette, la oss endelig la oss se på implementeringen getCurrentState():

state.js, del 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),
    };
  }
}

Vi behandler tre saker:

  1. base < 0 betyr at det ikke er noen oppdateringer før gjeldende gjengivelsestid (se implementering ovenfor getBaseUpdate()). Dette kan skje rett ved starten av spillet på grunn av gjengivelsesforsinkelse. I dette tilfellet bruker vi den siste oppdateringen som ble mottatt.
  2. base er den siste oppdateringen vi har. Dette kan skje på grunn av nettverksforsinkelse eller dårlig internettforbindelse. Også i dette tilfellet bruker vi den siste oppdateringen vi har.
  3. Vi har en oppdatering både før og etter gjeldende gjengivelsestid, så vi kan interpolere!

Alt som er igjen state.js er en implementering av lineær interpolasjon som er enkel (men kjedelig) matematikk. Hvis du vil utforske det selv, åpne state.jsGithub.

Del 2. Backend-server

I denne delen skal vi se på Node.js-backend som styrer vår eksempel på et .io-spill.

1. Serverinngangspunkt

For å administrere webserveren vil vi bruke et populært nettrammeverk for Node.js kalt Uttrykke. Den vil bli konfigurert av vår serverinngangspunktfil src/server/server.js:

server.js, del 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}`);

Husker du at i den første delen diskuterte vi Webpack? Det er her vi skal bruke Webpack-konfigurasjonene våre. Vi vil bruke dem på to måter:

  • Bruk webpack-dev-middleware å automatisk gjenoppbygge utviklingspakkene våre, eller
  • Overfør en mappe statisk dist/, der Webpack vil skrive filene våre etter produksjonsbygget.

En annen viktig oppgave server.js består av å sette opp serveren stikkontakt.iosom bare kobles til Express-serveren:

server.js, del 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);
});

Etter å ha etablert en socket.io-forbindelse med serveren, konfigurerer vi hendelsesbehandlere for den nye kontakten. Hendelsesbehandlere behandler meldinger mottatt fra klienter ved å delegere til et enkeltstående objekt game:

server.js, del 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);
}

Vi lager et .io-spill, så vi trenger bare én kopi Game ("Spill") - alle spillere spiller på samme arena! I neste avsnitt skal vi se hvordan denne klassen fungerer Game.

2. Spillservere

Klasse Game inneholder den viktigste logikken på serversiden. Den har to hovedoppgaver: spillerledelse и spillsimulering.

La oss starte med den første oppgaven - å administrere spillerne.

game.js, del 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);
    }
  }

  // ...
}

I dette spillet vil vi identifisere spillere etter felt id deres socket socket.io (hvis du er forvirret, gå tilbake til server.js). Socket.io selv tildeler hver socket en unik id, så vi trenger ikke å bekymre oss for det. jeg skal ringe ham Spiller ID.

Med det i tankene, la oss undersøke forekomstvariablene i klassen Game:

  • sockets er et objekt som binder spiller-ID-en til kontakten som er knyttet til spilleren. Det lar oss få tilgang til stikkontakter etter deres spiller-ID over tid.
  • players er et objekt som binder spiller-IDen til koden>Spiller-objektet

bullets er en rekke objekter Bullet, ikke har en bestemt rekkefølge.
lastUpdateTime – Dette er tidsstemplet til den siste spilloppdateringen. Vi får se hvordan det blir brukt snart.
shouldSendUpdate er en hjelpevariabel. Vi vil også se bruken av den snart.
fremgangsmåter addPlayer(), removePlayer() и handleInput() ikke nødvendig å forklare, de brukes i server.js. Hvis du trenger en oppfriskning, gå litt høyere tilbake.

Siste linje constructor() starter opp oppdateringssyklus spill (med en frekvens på 60 oppdateringer/s):

game.js, del 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;
    }
  }

  // ...
}

metode update() inneholder sannsynligvis den viktigste delen av logikken på serversiden. La oss liste opp alt det gjør i rekkefølge:

  1. Regner ut hva klokken er dt det har vært siden sist update().
  2. Oppdaterer hvert prosjektil og ødelegger dem om nødvendig. Vi vil se implementeringen av denne funksjonaliteten senere. Foreløpig er det nok for oss å vite det bullet.update() returnerer true, hvis prosjektilet må ødelegges (han gikk utenfor arenaen).
  3. Oppdaterer hver spiller og lager et prosjektil om nødvendig. Vi vil også se denne implementeringen senere - player.update() kan returnere et objekt Bullet.
  4. Sjekker for kollisjoner mellom prosjektiler og spillere som bruker applyCollisions(), som returnerer en rekke prosjektiler som treffer spillere. For hvert prosjektil som returneres, øker vi poengsummen til spilleren som avfyrte det (ved å bruke player.onDealtDamage()), og fjern deretter prosjektilet fra arrayet bullets.
  5. Varsler og ødelegger alle drepte spillere.
  6. Sender en spilloppdatering til alle spillere hvert sekund ganger når du ringer update(). Hjelpevariabelen nevnt ovenfor hjelper oss å spore dette shouldSendUpdate. Som update() kalt 60 ganger/s, vi sender spilloppdateringer 30 ganger/s. Dermed, klokkefrekvens serveren er 30 klokkesykluser/s (vi snakket om klokkefrekvensen i første del).

Hvorfor bare sende spilloppdateringer gjennom tid ? For å lagre kanal. 30 spilloppdateringer per sekund er mye!

Hvorfor ikke bare ringe da? update() 30 ganger i sekundet? For å forbedre spillsimuleringen. Jo oftere det kalles update(), jo mer nøyaktig blir spillsimuleringen. Men ikke la deg rive med av antallet utfordringer update(), fordi dette er en beregningsmessig dyr oppgave - 60 per sekund er ganske nok.

Resten av klassen Game består av hjelpemetoder som brukes i update():

game.js, del 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() Det er ganske enkelt – det sorterer spillere etter poengsum, tar de fem beste og returnerer brukernavn og poengsum for hver.

createUpdate() brukt i update() å lage spilloppdateringer som distribueres til spillere. Dens hovedoppgave er å kalle metoder serializeForUpdate(), implementert for klasser Player и Bullet. Merk at den kun overfører data til hver spiller om nærmeste spillere og prosjektiler - det er ikke nødvendig å overføre informasjon om spillobjekter som befinner seg langt fra spilleren!

3. Spillobjekter på serveren

I spillet vårt er prosjektiler og spillere faktisk veldig like: de er abstrakte, runde bevegelige spillobjekter. For å dra nytte av denne likheten mellom spillere og prosjektiler, la oss starte med å implementere en 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,
    };
  }
}

Det er ikke noe komplisert som skjer her. Denne timen vil være et godt utgangspunkt for utvidelse. La oss se hvordan klassen Bullet bruker 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;
  }
}

implementering Bullet veldig kort! Vi har lagt til Object bare følgende utvidelser:

  • Bruker pakken kortid for tilfeldig generering id prosjektil.
  • Legger til et felt parentID, slik at du kan spore spilleren som laget dette prosjektilet.
  • Legger returverdien til update(), som er lik true, hvis prosjektilet er utenfor arenaen (husker du at vi snakket om dette i forrige avsnitt?).

La oss gå videre til 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,
    };
  }
}

Spillere er mer komplekse enn prosjektiler, så denne klassen bør lagre noen flere felt. Metoden hans update() gjør mer arbeid, spesielt å returnere det nyopprettede prosjektilet hvis det ikke er noen igjen fireCooldown (husker du at vi snakket om dette i forrige avsnitt?). Det utvider også metoden serializeForUpdate(), fordi vi må inkludere flere felt for spilleren i spilloppdateringen.

Tilgjengelighet av en basisklasse Object - et viktig skritt for å unngå koderepetisjon. For eksempel uten klasse Object hvert spillobjekt må ha samme implementering distanceTo(), og å kopiere og lime inn alle disse implementeringene på tvers av flere filer ville være et mareritt. Dette blir spesielt viktig for store prosjekter, når antall utvidende Object klassene vokser.

4. Kollisjonsdeteksjon

Det eneste som gjenstår for oss er å gjenkjenne når prosjektilene treffer spillerne! Husk denne kodebiten fra metoden update() i klassen 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),
    );

    // ...
  }
}

Vi må implementere metoden applyCollisions(), som returnerer alle prosjektiler som treffer spillere. Heldigvis er dette ikke så vanskelig å gjøre fordi

  • Alle kolliderende objekter er sirkler, og dette er den enkleste formen for å implementere kollisjonsdeteksjon.
  • Vi har allerede en metode distanceTo(), som vi implementerte i klassen i forrige del Object.

Slik ser implementeringen vår av kollisjonsdeteksjon ut:

kollisjoner.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;
}

Denne enkle kollisjonsdeteksjonen er basert på det faktum at to sirkler kolliderer hvis avstanden mellom sentrene deres er mindre enn summen av radiene deres. Her er et tilfelle der avstanden mellom sentrene til to sirkler er nøyaktig lik summen av radiene deres:

Opprette et flerspillernettspill i .io-sjangeren
Her må du være nøye med et par aspekter til:

  • Prosjektilet må ikke treffe spilleren som laget det. Dette kan oppnås ved å sammenligne bullet.parentID с player.id.
  • Prosjektilet skal bare treffe én gang i det ekstreme tilfellet å treffe flere spillere samtidig. Vi vil løse dette problemet ved å bruke operatøren break: Når en spiller som kolliderer med et prosjektil er funnet, slutter vi å søke og går videre til neste prosjektil.

end

Det er alt! Vi har dekket alt du trenger å vite for å lage et .io-nettspill. Hva blir det neste? Bygg ditt eget .io-spill!

All eksempelkode er åpen kildekode og lagt ut på Github.

Kilde: www.habr.com

Legg til en kommentar