Opprette et flerspillernettspill i .io-sjangeren

Opprette et flerspillernettspill i .io-sjangeren
Utgitt i 2015 Agar.io ble stamfar til en ny sjanger .io-spill, som har vokst i popularitet siden den gang. Jeg har opplevd fremveksten av .io-spill pÄ nÊrt hold: i lÞpet av de siste tre Ärene har jeg laget og solgte to spill i denne sjangeren..

Hvis du aldri har hĂžrt om dem fĂžr, er de gratis, nettbaserte flerspillerspill som er enkle Ă„ spille (ingen konto kreves). De setter vanligvis flere motspillere opp mot hverandre i en arena. Andre kjente .io-spill inkluderer: Slither.io Đž Diep.io.

I dette innlegget skal vi finne ut hvordan lag et .io-spill fra bunnen avAlt du trenger Ä vite er Javascript: du mÄ forstÄ ting som syntaks ES6, nÞkkelord this О PromisesSelv om du ikke kan Javascript perfekt, vil du fortsatt kunne forstÄ mesteparten av dette innlegget.

Eksempel pÄ .io-spill

For Ä hjelpe deg med lÊringen, vil vi henvise til eksempel pÄ .io-spillPrÞv Ä spille det!

Opprette et flerspillernettspill i .io-sjangeren
Spillet er ganske enkelt: du kontrollerer et skip i en arena der det er andre spillere. Skipet ditt avfyrer automatisk prosjektiler, og du prÞver Ä treffe de andre spillerne mens du unngÄr prosjektilene deres.

1. Prosjektoversikt/-struktur

anbefale last ned kildekode eksempelspill slik at du kan fĂžlge med meg.

Eksemplet bruker fĂžlgende:

  • Uttrykke – det mest populĂŠre webrammeverket for Node.js, som kjĂžrer spillets webserver.
  • socket.io — websocket-bibliotek for utveksling av data mellom nettleseren og serveren.
  • Webpack — modulbehandler. Du kan lese om hvorfor du bĂžr bruke Webpack her.

Slik ser prosjektets katalogstruktur ut:

offentlig/ressurser/ ... src/klient/css/ ... html/index.html index.js ... server/server.js ... delt/konstanter.js

offentlig/

Alt er i mappen public/ vil bli statisk overfÞrt av serveren. public/assets/ inneholder bilder brukt i prosjektet vÄrt.

src /

All kildekoden er i mappen src/. Titler client/ О server/ snakke for seg selv, og shared/ inneholder en konstantfil importert av bÄde klienten og serveren.

2. Samlinger/prosjektparametere

Som nevnt ovenfor bruker vi modulbehandleren til Ä bygge prosjektet. WebpackLa oss ta en titt pÄ Webpack-konfigurasjonen vÄr:

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:

  • src/client/index.js — Dette er inngangspunktet for Javascript (JS)-klienten. Webpack starter herfra og sĂžker rekursivt etter andre importerte filer.
  • Utdata-JS-filen fra Webpack-bygget vĂ„rt vil bli plassert i katalogen dist/Jeg vil kalle denne filen vĂ„r JS-pakke.
  • Vi bruker Babel, og spesielt konfigurasjonen @babel/forhĂ„ndsinnstilt-konvolutt Ă„ transpilere JS-koden vĂ„r for eldre nettlesere.
  • Vi bruker en plugin for Ă„ trekke ut all CSS-en som JS-filene refererer til og kombinere den pĂ„ ett sted. Jeg kaller den vĂ„r CSS-pakke.

Du har kanskje lagt merke til merkelige navn pĂ„ pakkefiler. '[name].[contenthash].ext'. De inneholder erstatninger av filnavn Nettpakke: [name] vil bli erstattet av navnet pĂ„ inngangspunktet (i vĂ„rt tilfelle er det game), a [contenthash] vil bli erstattet med hashen til filinnholdet. Vi gjĂžr dette slik at optimalisere prosjektet for hashing — vi kan fortelle nettlesere at de skal mellomlagre JS-pakkene vĂ„re pĂ„ ubestemt tid fordi Hvis en pakke endres, endres ogsĂ„ filnavnet (endringer contenthash). Det ferdige resultatet vil vĂŠre et filnavn pĂ„ skjemaet game.dbeee76e91a97d0c7207.js.

fil webpack.common.js — dette er den grunnleggende konfigurasjonsfilen som vi importerer til utviklings- 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 effektivitets skyld bruker vi i utviklingsprosessen webpack.dev.js, og bytter til webpack.prod.jsfor Ă„ optimalisere pakkestĂžrrelser ved distribusjon til produksjon.

Lokalt oppsett

Jeg anbefaler Ä installere prosjektet pÄ en lokal maskin, slik at du kan fÞlge trinnene som er oppfÞrt i dette innlegget. Oppsettet er enkelt: fÞrst mÄ du ha Node О NPM. Det neste du mÄ gjÞre

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

og du er klar! For Ă„ starte utviklingsserveren, kjĂžr ganske enkelt

$ npm run develop

og gĂ„ til nettleseren pĂ„ localhost: 3000Utviklingsserveren vil automatisk gjenoppbygge JS- og CSS-pakker nĂ„r du endrer koden din – bare oppdater siden for Ă„ se alle endringene!

3. Klientinngangspunkter

La oss begynne med selve spillkoden. FÞrst trenger vi en side index.html, nÄr du besÞker nettstedet, vil nettleseren laste det inn fÞrst. Siden vÄr vil vÊre ganske enkel:

index.html

Et eksempel pÄ et .io-spill  SPILLE

Dette kodeeksemplet er litt forenklet for klarhetens skyld, og jeg vil gjÞre det samme med mange andre eksempler i innlegget. Hele koden kan alltid sees pÄ Github.

Vi har:

  • HTML5 Canvas-element (<canvas>), som vi skal bruke til Ă„ rendre spillet.
  • <link> for Ă„ legge til CSS-pakken vĂ„r.
  • <script> for Ă„ legge til Javascript-pakken vĂ„r.
  • Hovedmeny med brukernavn <input> og «SPILL AV»-knappen (<button>).

NÄr hjemmesiden er lastet inn, vil nettleseren begynne Ä kjÞre Javascript-kode, og starte med JS-filen for inngangspunktet: 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 egentlig ikke sÄ mye som skjer her:

  1. Importerer noen andre JS-filer.
  2. Importer CSS (slik at Webpack vet at de skal inkluderes i CSS-pakken vÄr).
  3. lanseringen connect() for Ă„ opprette en forbindelse til serveren og starte downloadAssets() for Ă„ laste ned bilder som trengs for Ă„ rendre spillet.
  4. Etter Ă„ ha fullfĂžrt trinn 3 hovedmenyen vises (playMenu).
  5. Setter opp klikkhÄndtereren for «SPILL»-knappen. NÄr knappen klikkes, initialiserer koden spillet og forteller serveren at vi er klare til Ä spille.

Hoved"kjerne" i klient-server-logikken vÄr ligger i filene som ble importert av filen index.jsNÄ skal vi vurdere dem alle i rekkefÞlge.

4. Utveksling av kundedata

I dette spillet bruker vi et velkjent bibliotek for Ă„ kommunisere med serveren. socket.ioSocket.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 den samme forbindelsen.

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

nettverk.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 hovedhandlinger som utfĂžres i denne filen:

  • Vi prĂžver Ă„ koble til serveren. connectedPromise er kun tillatt nĂ„r vi har opprettet en forbindelse.
  • Hvis forbindelsen er opprettet, registrerer vi tilbakeringingsfunksjoner (processGameUpdate() Đž onGameOver()) for meldinger 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 det, mÄ vi laste ned alle bildene (ressursene) som trengs for Ä gjÞre det. La oss skrive en ressursbehandler:

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

RessurshÄndtering er ikke sÄ vanskelig Ä implementere! Hovedideen er Ä lagre et objekt assets, som vil binde filnavnnÞkkelen til objektverdien ImageNÄr ressursen er lastet inn, lagrer vi den til et objekt. assets for rask henting i fremtiden. NÄr nedlasting av hver enkelt ressurs er tillatt (dvs. vil bli lastet ned alle ressurser), tillater vi downloadPromise.

NÄr ressursene er nedlastet, kan vi begynne Ä gjengi. Som nevnt tidligere bruker vi HTML5 lerret (<canvas>). Spillet vÄrt er ganske enkelt, sÄ vi trenger bare Ä tegne fÞlgende:

  1. bakgrunn
  2. Spillerens skip
  3. Andre spillere i spillet
  4. Skjell

Her er de viktige fragmentene src/client/render.js, som tegner 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() – hovedfunksjonen til denne filen. startRendering() о stopRendering() kontrollere aktiveringen av 60 FPS-gjengivelsessyklusen.

Konkrete implementeringer av individuelle gjengivelseshjelpefunksjoner (f.eks. renderBullet()) er ikke sÄ viktige, men her er et 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 Ă„ lĂŠre flere hjelpefunksjoner for gjengivelse, kan du lese resten av denne artikkelen. src/klient/render.js.

6. Klientinnspill

Det er pÄ tide Ä lage et spill spillbarKontrollskjemaet vil vÊre veldig enkelt: for Ä endre bevegelsesretningen kan du bruke musen (pÄ en datamaskin) eller berÞre skjermen (pÄ en mobil enhet). For Ä implementere dette vil vi registrere Eventlyttere for mus- og berÞringshendelser.
Alt dette vil bli tatt hÄnd om 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() — dette er hendelseslyttere som kaller updateDirection() (av networking.js) nĂ„r en input-hendelse oppstĂ„r (for eksempel nĂ„r musen beveges). updateDirection() hĂ„ndterer utvekslingen av meldinger med serveren, som behandler input-hendelsen og oppdaterer spillstatusen deretter.

7. Klientstatus

Denne delen er den vanskeligste i den fÞrste delen av innlegget. Ikke bli lei deg hvis du ikke forstÄr den fÞrste gang du leser den! Du kan til og med hoppe over den og komme tilbake til den senere.

Den siste brikken i puslespillet som trengs for Ä fullfÞre klient-server-koden er statHusker du kodebiten fra delen «Klientgjengivelse»?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() burde kunne gi oss den nÄvÊrende tilstanden til spillet 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:

  • tServerens tidsstempel som angir nĂ„r denne oppdateringen ble opprettet.
  • meInformasjon om spilleren som mottar denne oppdateringen.
  • andreen rekke informasjon om andre spillere som deltar i det samme spillet.
  • kuler: en rekke informasjoner om prosjektiler i spillet.
  • leaderboard: nĂ„vĂŠrende resultattavledata. Vi tar ikke hensyn til det i dette innlegget.

7.1 Klientens naive tilstand

Naiv implementering getCurrentState() kan bare returnere dataene fra den nyeste spilloppdateringen som ble mottatt direkte.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Fint og tydelig! Men om bare alt var sÄ enkelt. En av grunnene til at en slik implementering er problematisk: den begrenser gjengivelsesrammefrekvensen til serverens klokkefrekvens.

Bildefrekvensantall rammer (dvs. anrop render()) per sekund, eller FPS. Spill tar vanligvis sikte pÄ Ä oppnÄ minst 60 FPS.

Tick Rate: Hyppigheten serveren sender spilloppdateringer til klienter med. Den er ofte lavere enn bildefrekvensen.I spillet vÄrt kjÞrer serveren med 30 ticks per sekund.

Hvis vi bare rendrer den nyeste oppdateringen av spillet, vil FPS-en i hovedsak aldri kunne overstige 30, fordi Vi fÄr aldri mer enn 30 oppdateringer per sekund fra serverenSelv om vi ringer render() 60 ganger per sekund, vil halvparten av disse kallene ganske enkelt tegne det samme pÄ nytt, og i hovedsak ikke gjÞre noe. Et annet problem med den naive implementeringen er at den med forbehold om forsinkelserMed 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 ville vĂŠrt:
Opprette et flerspillernettspill i .io-sjangeren
Den naive implementeringen er nesten det verste tilfellet nÄr det gjelder latens. Hvis en spilloppdatering mottas med en forsinkelse pÄ 50 ms, da klienten bremser ned 50 ms ekstra fordi den fortsatt gjengir spillstatusen fra forrige oppdatering. Du kan tenke deg hvor upraktisk dette er for spilleren: spillet vil fÞles hakkete og ustabilt pÄ grunn av tilfeldige nedbremsinger.

7.2 Forbedret klienttilstand

Vi vil gjÞre noen forbedringer i den naive implementeringen. FÞrst bruker vi gjengivelsesforsinkelse med 100 ms. Dette betyr at klientens "nÄvÊrende" tilstand alltid vil vÊre 100 ms bak spilltilstanden pÄ serveren. Hvis for eksempel tiden pÄ serveren er 150, sÄ vil klienten gjengi tilstanden serveren var i pÄ det tidspunktet 50:

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

Opprette et flerspillernettspill i .io-sjangeren
Prisen Ă„ betale for dette vil vĂŠre konstant inndataforsinkelse med 100 ms. Dette er et lite offer for jevn spilling – de fleste spillere (spesielt de som spiller avslappet) vil ikke engang merke denne forsinkelsen. Folk har mye lettere for Ă„ tilpasse seg en konstant forsinkelse pĂ„ 100 ms enn Ă„ spille med uforutsigbar forsinkelse.

Vi kan ogsÄ bruke en annen teknikk som kalles "klientsideprognoser", som gjÞr en god jobb med Ä redusere opplevd latens, men som ikke vil bli dekket i dette innlegget.

En annen forbedring vi bruker er lineÊr interpolasjonPÄ grunn av gjengivelsesforsinkelsen er vi vanligvis minst én oppdatering foran gjeldende tidspunkt pÄ klienten. NÄr getCurrentState(), vi kan gjÞre det lineÊr interpolasjon mellom spilloppdateringer rett fÞr og etter gjeldende tidspunkt i klienten:

Opprette et flerspillernettspill i .io-sjangeren
Dette lÞser problemet med bildefrekvens: nÄ kan vi gjengi unike bilder i hvilken som helst hastighet vi Þnsker!

7.3 Realisering av den forbedrede klienttilstanden

Eksempel pÄ implementering i src/client/state.js bruker bÄde gjengivelsesforsinkelse og lineÊr interpolasjon, men det varer ikke lenge. La oss dele koden inn 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 den gjÞr currentServerTime()Som vi sÄ tidligere, inkluderer hver spilloppdatering et tidsstempel for serveren. Vi Þnsker Ä bruke gjengivelsesforsinkelse for Ä gjengi bildet 100 ms bak serveren, men Vi vil aldri vite hva klokken er 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 umiddelbartHvis dette var sant, ville vi visst servertiden pÄ det bestemte tidspunktet! Vi lagrer serverens tidsstempel i firstServerTimestamp og redde vÄre lokal (klient) tidsstempel pÄ samme tidspunkt i gameStart.

Å, vent litt. Burde ikke servertid vĂŠre = klienttid? Hvorfor skiller vi mellom «servertidsstempel» og «klienttidsstempel»? Det er et godt spĂžrsmĂ„l! Det viser seg at de ikke er det samme. Date.now() vil returnere forskjellige tidsstempler pĂ„ klienten og serveren, og dette avhenger av faktorer lokale for disse maskinene. Ikke anta at tidsstemplene vil vĂŠre de samme pĂ„ alle maskiner.

NÄ forstÄr vi hva den gjÞr currentServerTime()han kommer tilbake servertidsstempel for gjeldende gjengivelsestidMed andre ord, dette er gjeldende servertid (firstServerTimestamp <+ (Date.now() - gameStart)) minus gjengivelsesforsinkelse (RENDER_DELAY).

La oss nÄ finne ut hvordan vi hÄndterer spilloppdateringer. NÄr en oppdatering mottas fra serveren, processGameUpdate(), og vi lagrer den nye oppdateringen til arrayet gameUpdatesDeretter, for Ä sjekke minnebruken, sletter vi alle gamle oppdateringer fÞr grunnleggende oppdatering, fordi vi ikke trenger dem lenger.

Hva er en «kjerneoppdatering»? Det er den fÞrste oppdateringen vi finner beveger seg bakover fra gjeldende servertidHusker du dette diagrammet?

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

Hva er hensikten med en kjerneoppdatering? Hvorfor kan vi rulle tilbake oppdateringer til en kjerneoppdatering? 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 gjengivelsestidspunkt (se implementering ovenfor) getBaseUpdate()). Dette kan skje rett i starten av spillet pÄ grunn av gjengivelsesforsinkelser. I dette tilfellet bruker vi den siste oppdateringen som er mottatt.
  2. base — Dette er den siste oppdateringen vi har. Dette kan skje pĂ„ grunn av nettverksforsinkelse eller dĂ„rlig internettforbindelse. I dette tilfellet bruker vi ogsĂ„ den siste oppdateringen vi har.
  3. Vi har en oppdatering bÄde fÞr og etter gjeldende gjengivelsestidspunkt, slik at du kan interpolere!

Alt som er igjen i state.js — er en implementering av lineĂŠr interpolasjon, som er enkel (men kjedelig) matematikk. Hvis du vil lĂŠre det selv, sjekk ut state.js pĂ„ Github.

Del 2. Backend-server

I denne delen skal vi se pÄ Node.js-backend-en som driver vÄr eksempel pÄ .io-spill.

1. Serverinngangspunkt

For Ä administrere webserveren bruker vi et populÊrt webrammeverk for Node.js kalt UttrykkeServerens inngangspunktfil vil hÄndtere konfigurasjonen. 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 vi diskuterte Webpack i del 1? Det er her vi skal bruke Webpack-konfigurasjonene vÄre. Vi skal bruke dem pÄ to mÄter:

  • Bruk webpack-utvikler-mellomvare Ă„ automatisk gjenoppbygge utviklingspakkene vĂ„re, eller
  • Statisk overfĂžring av mapper dist/, som Webpack vil skrive filene vĂ„re inn i etter Ă„ ha bygget produksjon.

En annen viktig oppgave server.js bestÄr av Ä sette opp serveren socket.io, som ganske enkelt kobler seg 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 at vi har opprettet en socket.io-tilkobling til serveren, konfigurerer vi hendelseshÄndterere for den nye socketen. HendelseshÄndterere behandler meldinger mottatt fra klienter ved Ä delegere til et singleton-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 instans. Game («Spill») – alle spillere spiller i 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: spilleradministrasjon О spillsimulering.

La oss starte med den fþrste oppgaven – spilleradministrasjon.

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 denne kampen skal vi identifisere spillere etter banen id deres socket.io (hvis du blir forvirret, gÄ tilbake til server.js). Socket.io tildeler selv hver socket en unik id, sÄ vi trenger ikke Ä bekymre oss for det. Jeg ringer ham. Spiller ID.

Med dette i tankene, la oss utforske instansvariabler i en klasse Game:

  • sockets — er et objekt som binder en spiller-ID til en socket som er tilknyttet spilleren. Det lar oss fĂ„ tilgang til sockets via spiller-ID-ene deres nĂ„r som helst.
  • players — er et objekt som binder spiller-ID-en til koden>Spiller-objektet

bullets - er en rekke objekter Bullet, som ikke har noen spesifikk rekkefĂžlge.
lastUpdateTime — Dette er tidsstempelet for siste gang spillet ble oppdatert. Vi fĂ„r se hvordan det brukes snart.
shouldSendUpdate — er en hjelpevariabel. Vi skal ogsĂ„ se bruken av den snart.
fremgangsmÄter addPlayer(), removePlayer() О handleInput() Trenger ikke Ä forklare, de brukes i server.jsHvis du trenger Ä friske opp hukommelsen, gÄ litt hÞyere tilbake.

Den siste linjen constructor() starter opp oppdateringssyklus spill (med 60 oppdateringsfrekvens/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 serversidelogikken. La oss liste opp alt den gjĂžr i rekkefĂžlge:

  1. Regner ut hvor mye tid 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 at bullet.update() returnerer true, hvis prosjektilet mÄ Þdelegges (han gikk utenfor arenagrensene).
  3. Oppdaterer hver spiller og lager et prosjektil om nĂždvendig. Vi fĂ„r se denne implementeringen senere ogsĂ„ – 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 matrisen bullets.
  5. Varsler og Ăždelegger alle drepte spillere.
  6. Sender en spilloppdatering til alle spillere annenhver én gang per samtale update()Hjelpevariabelen nevnt ovenfor hjelper oss med Ä spore dette. shouldSendUpdate. Som update() kalles 60 ganger/sek, sender vi spilloppdateringer 30 ganger/sek. SÄ, klokkefrekvens serveren er lik 30 sykluser/s (vi snakket om syklusfrekvensen i fÞrste del).

Hvorfor bare sende spilloppdateringer annenhver gang ? For Ä spare bÄndbredde er 30 spilloppdateringer per sekund mye!

Hvorfor ikke bare ringe da? update() 30 ganger i sekundet? For Ă„ forbedre simuleringen av spillet. Jo oftere det kalles update(), desto mer nĂžyaktig blir simuleringen av spillet. Men ikke bli for revet med av antall samtaler update(), fordi det er en beregningsmessig dyr oppgave – 60 per sekund er helt 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 av dem.

createUpdate() brukt i update() Ă„ lage spilloppdateringer som leveres til spillere. Hovedoppgaven er Ă„ kalle metoder serializeForUpdate(), implementert for klasser Player Đž BulletMerk at den bare overfĂžrer data til hver spiller omtrent nĂŠrmeste spillere og prosjektiler - det er ikke nĂždvendig Ă„ overfĂžre informasjon om spillobjekter som er 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 begynne med Ä implementere en basisklasse. Object:

objekt.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 klassen vil vÊre et godt utgangspunkt for utvidelse. La oss se hvordan klassen gÄr. 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 la til Object bare fĂžlgende utvidelser:

  • Bruk av pakken kortnavn for tilfeldig generering id prosjektil.
  • Legge til et felt parentID, slik at spilleren som laget prosjektilet kan spores.
  • Legge til en returverdi 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:

spiller.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 noe igjen fireCooldown (husker du at vi snakket om dette i forrige avsnitt?) Den utvider ogsÄ metoden serializeForUpdate(), fordi vi mÄ inkludere flere felt for spilleren i spilloppdateringen.

TilstedevĂŠrelsen av en basisklasse Object – et viktig skritt for Ă„ unngĂ„ koderepetisjonFor eksempel uten klasse Object Hvert spillobjekt mĂ„ ha samme implementering distanceTo(), og Ă„ synkronisere kopiering og liming av alle disse implementasjonene inn i flere filer ville vĂŠre et mareritt. Dette blir spesielt viktig for store prosjekter., nĂ„r antallet ekspanderende Object klassene vokser.

4. Kollisjonsdeteksjon

Det eneste som gjenstÄr for oss er Ä gjenkjenne nÄr prosjektiler treffer spillere! Husk denne kodebiten fra metoden update() i klassen Game:

spill.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 ikke dette sÄ vanskelig Ä gjÞre, fordi

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

Slik ser implementeringen 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 sentrumene deres er mindre enn summen av radiene deresHer er et tilfelle der avstanden mellom sentrum av to sirkler er nÞyaktig lik summen av radiene deres:

Opprette et flerspillernettspill i .io-sjangeren
Det er et par flere aspekter som mÄ tas i betraktning her:

  • 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 ekstreme tilfeller av samtidig kollisjon med flere spillere. Vi lĂžser dette problemet ved hjelp av operatoren breakNĂ„r en spiller som har kollidert med et prosjektil blir funnet, stopper vi sĂžket og gĂ„r videre til neste prosjektil.

end

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

All eksempelkoden er Äpen kildekode og er lagt ut pÄ Github.

Kilde: www.habr.com

KjĂžp pĂ„litelig hosting for nettsteder med DDoS-beskyttelse, VPS VDS-servere đŸ”„ KjĂžp pĂ„litelig webhotell med DDoS-beskyttelse, VPS VDS-servere | ProHoster