Skapa ett flerspelart webspel i .io-genren

Skapa ett flerspelart webspel i .io-genren
Utgiven 2015 Agar.io blev föregÄngaren till en ny genre av spel .io, vars popularitet har ökat kraftigt sedan dess. Jag har sjÀlv upplevt tillvÀxten av populariteten för .io-spel: under de senaste tre Ären har jag skapat och sÄlt tvÄ spel inom denna genre..

Om du aldrig har hört talas om sÄdana spel tidigare: det Àr gratis multiplayer-webbspel som Àr lÀtta att delta i (utan konto krÀvs). De sÀtts vanligtvis pÄ en arena med mÄnga motstridiga spelare. Andra kÀnda spel inom .io-genren: Slither.io och Diep.io.

I det hĂ€r inlĂ€gget ska vi undersöka hur vi frĂ„n grunden skapar ett .io-spel. För detta behövs endast kunskaper i Javascript: du behöver förstĂ„ saker som syntaxen ES6, nyckelordet this och Promises. Även om du inte Ă€r fullĂ€rd i Javascript kommer du Ă€ndĂ„ kunna förstĂ„ det mesta av inlĂ€gget.

Exempel pÄ ett .io-spel

För att hjÀlpa till med lÀrandet kommer vi att referera till exemplet pÄ ett .io-spel. Försök att spela det!

Skapa ett flerspelart webspel i .io-genren
Spelet Àr ganska enkelt: du styr ett fartyg pÄ arenan dÀr det finns andra spelare. Ditt fartyg skjuter automatiskt projektiler och du försöker trÀffa andra spelare, samtidigt som du undviker deras projektiler.

1. Översikt/struktur av projektet

Jag rekommenderar att ladda ner kÀllkoden för exemplet pÄ spelet sÄ att du kan följa med mig.

Exemplet anvÀnder följande:

  • Express — det mest populĂ€ra webb-ramverket för Node.js som styr spelets webbserver.
  • socket.io — ett websocket-bibliotek för att överföra data mellan webblĂ€saren och servern.
  • Webpack — modulhanterare. Om varför man ska anvĂ€nda Webpack kan man lĂ€sa hĂ€r.

SÄ hÀr ser projektkatalogens struktur ut:

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

public/

Allt i mappen public/ kommer att överföras statiskt av servern. I public/assets/ finns de bilder som vÄrt projekt anvÀnder.

src/

All kÀllkod finns i mappen src/. Namnen client/ och server/ talas för sig sjÀlva, och shared/ innehÄller en konstantfil som importeras bÄde av klienten och servern.

2. Byggprocesser/parameter för projektet

Som nÀmnts ovan anvÀnder vi modulhanteraren för att bygga projektet. WebpackLÄt oss ta en titt pÄ vÄr Webpack-konfiguration:

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 viktigaste raderna hÀr Àr:

  • src/client/index.js — detta Ă€r klientens Javascript (JS) intrĂ€despunkt. Webpack kommer att börja hĂ€rifrĂ„n och rekursivt leta efter andra importerade filer.
  • UtgĂ„ende JS frĂ„n vĂ„r Webpack-bygge kommer att finnas i katalogen dist/. Jag kommer att kalla denna fil vĂ„r JS-packe.
  • Vi anvĂ€nder Babel, och specifikt konfigurationen @babel/preset-env för att transpila vĂ„r JS-kod för Ă€ldre webblĂ€sare.
  • Vi anvĂ€nder en plugin för att extrahera all CSS som refereras av JS-filer och för att sammanfoga dem pĂ„ ett stĂ€lle. Jag kommer att kalla det vĂ„r CSS-packe.

Du kanske har lagt mĂ€rke till de konstiga filnamnen för paketen '[name].[contenthash].ext'. De innehĂ„ller filnamnsplatshĂ„llare Webpack: [name] kommer att ersĂ€ttas med namnet pĂ„ intrĂ€despunkten (i vĂ„rt fall Ă€r det game), och [contenthash] kommer att ersĂ€ttas med hash av filens innehĂ„ll. Vi gör detta för att optimera projektet för caching — det gör att webblĂ€sare kan cache-lagra vĂ„ra JS-paket oĂ€ndligt, eftersom om paketet Ă€ndras, Ă€ndras Ă€ven filens namn (Ă€ndras contenthash). Det fĂ€rdiga resultatet kommer att vara filnamn av typen game.dbeee76e91a97d0c7207.js.

Fil webpack.common.js — detta Ă€r basfilen för konfigurationen som vi importerar i utvecklings- och produktionskonfigurationen. HĂ€r Ă€r till exempel utvecklingskonfigurationen:

webpack.dev.js

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

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

För effektivitet anvÀnder vi i utvecklingsprocessen webpack.dev.js, och vÀxlar till webpack.prod.js, för att optimera paketstorlekar vid utplacering till produktion.

Lokal installation

Jag rekommenderar att du installerar projektet pÄ din lokala maskin, sÄ att du kan följa stegen som anges i detta inlÀgg. Installation Àr enkel: först mÄste Node och NPMvara installerade. Efter det behöver du köra

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

och du Àr redo att börja! För att starta utvecklingsservern rÀcker det med att köra

$ npm run develop

och gĂ„ till webblĂ€saren pĂ„ localhost:3000. Utvecklingsservern kommer automatiskt att Ă„terskapa JS- och CSS-paketen under koden Ă€ndras — uppdatera bara sidan för att se alla Ă€ndringar!

3. Klientpunkter

LÄt oss börja med sjÀlva spelkoden. Först behöver vi sidan index.html, nÀr man besöker webbplatsen kommer den att laddas först. VÄr sida kommer att vara ganska enkel:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Ett exempel på ett .io-spel</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Användarnamn" />
    <button id="play-button">SPELA</button>
  </div>
</body>
</html>

Detta kodexempel Àr lite förenklat för att vara tydligt, och detsamma kommer jag att göra med mÄnga andra exempel i inlÀgget. Den kompletta koden kan alltid ses pÄ Github.

Vi har:

  • HTML5 Canvas-elementet (<canvas>), vilket vi kommer att anvĂ€nda för att rendera spelet.
  • <link> för att lĂ€gga till vĂ„rt CSS-paket.
  • <script> för att lĂ€gga till vĂ„rt Javascript-paket.
  • Huvudmeny med anvĂ€ndarnamn <input> och knappen «SPELA» (

Efter att startsidan har laddats i webblÀsaren kommer Javascript-koden att köras, med början frÄn ingÄngs-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 = () => {
    // Spela!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Det kan verka komplicerat, men egentligen hÀnder det inte sÄ mycket hÀr:

  1. Importera flera andra JS-filer.
  2. Importera CSS (sÄ att Webpack vet att det ska inkludera dem i vÄrt CSS-paket).
  3. Launch connect() för att etablera en anslutning till servern och starta downloadAssets() för att ladda ner bilder som behövs för att rendera spelet.
  4. Efter att steg 3 Àr slutfört visas huvudmenyn (playMenu).
  5. StÀlla in en handlare för knappen «SPELA». NÀr knappen trycks pÄ, initierar koden spelet och meddelar servern att vi Àr redo att spela.

Huvudlogiken för vÄr klient-server-interaktion finns i de filer som importerades av filen index.js. Nu ska vi gÄ igenom dem i tur och ordning.

4. Klientdatautbyte

I detta spel anvÀnder vi ett vÀlkÀnt bibliotek för att kommunicera med servern socket.io. Socket.io har inbyggt stöd WebSockets, som Àr vÀl lÀmpade för tvÄvÀgskommunikation: vi kan skicka meddelanden till servern och och servern kan skicka meddelanden till oss via samma anslutning.

Vi kommer att ha en fil src/client/networking.js, som kommer att hantera all kommunikation med servern: 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('Ansluten till servern!'); resolve(); }); });export const connect = onGameOver => ( connectedPromise.then(() => { \/\/ Registrera 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); };

networking.js

Den hÀr koden Àr ocksÄ nÄgot förkortad för tydlighet.

I denna fil sker tre huvudÄtgÀrder:

Vi försöker ansluta till servern.

  • connectedPromise upprĂ€ttas först nĂ€r vi har upprĂ€ttat en anslutning. Om anslutningen Ă€r framgĂ„ngsrik registrerar vi callback-funktioner (
  • processGameUpdate()onGameOver() och ) för meddelanden som vi kan fĂ„ frĂ„n servern.Vi exporterar
  • play() updateDirection() och , sĂ„ att andra filer kan anvĂ€nda dem.5. Klientrendering

Det Àr dags att visa bilden pÄ skÀrmen!


men innan vi kan göra det mĂ„ste vi ladda ner alla bilder (resurser) som behövs för detta. LĂ„t oss skriva en resursförvaltare:

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(`Nedladdad ${assetName}`); assets[assetName] = asset; resolve(); }; asset.src = `\/assets\/${assetName}`; }); }export const downloadAssets = () => downloadPromise; export const getAsset = assetName => assets[assetName];

assets.js

Att hantera resurser Àr inte sÄ svÄrt! HuvudpoÀngen Àr att lagra ett objekt

assets , som kopplar nyckeln av filnamnet till vÀrdet av objektet. NÀr resursen har laddats ner sparar vi den i objektet Imageför snabb Ätkomst i framtiden. NÀr nedladdningen av varje enskild resurs Àr tillÄten (det vill sÀga resurserna har laddats), tillÄter vi , som kopplar nyckeln av filnamnet till vÀrdet av objektet downloadPromise alla NÀr resurserna har laddats ner kan vi börja rendera. Som nÀmnts tidigare anvÀnder vi HTML5 Canvas.

). VÄrt spel Àr ganska enkelt, sÄ vi behöver bara rita det följande: ... (<canvas>...

  1. Bakgrund
  2. Spelarens skepp
  3. Andra spelare som Àr i spelet
  4. Projicerade skott

HÀr Àr viktiga fragment src/client/render.js, som renderar just de fyra punkterna ovan:

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;

// HÀmta grafikkontexten för canvasen
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Gör canvasen helskÀrms
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Rita bakgrunden
  renderBackground(me.x, me.y);

  // Rita alla skott
  bullets.forEach(renderBullet.bind(null, me));

  // Rita alla spelare
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... HjÀlpfunktioner hÀr uteslutna

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

Denna kod har ocksÄ förkortats för tydlighet.

render() Àr huvudfunktionen i denna fil. startRendering() och stopRendering() hanterar aktiveringen av renderingsloopen med en frekvens av 60 FPS.

Specifika implementationer av individuella hjÀlpfunktioner för rendering (t.ex. renderBullet()) Àr kanske inte sÄ viktiga, men hÀr Àr ett enkelt exempel:

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

Observera att vi anvÀnder metoden getAsset(), som vi tidigare sett i asset.js!

Om du Àr intresserad av att utforska andra hjÀlpfunktioner för rendering, lÀs resten av src/client/render.js.

6. Klientinmatning

Det Àr dags att göra spelet spelbart! Kontrollschemat kommer att vara mycket enkelt: du kan anvÀnda musen (pÄ datorn) eller beröra skÀrmen (pÄ mobilen) för att Àndra rörelseriktningen. För att genomföra detta kommer vi att registrera Event Listeners för mus- och touchhÀndelser.
Allt detta kommer att hanteras av 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() och onTouchInput() Àr Event Listeners som anropas , sÄ att andra filer kan anvÀnda dem. (frÄn networking.js) vid inmatningsevent (t.ex. vid musrörelse). , sÄ att andra filer kan anvÀnda dem. hanter kommunicerar med servern som behandlar inmatningshÀndelsen och uppdaterar spelets tillstÄnd dÀrefter.

7. Klientens tillstÄnd

Detta avsnitt Àr det mest utmanande i den första delen av inlÀgget. Bli inte nedslagen om du inte förstÄr det vid första genomlÀsningen! Du kan till och med hoppa över det och Äterkomma senare.

Den sista pusselbiten som behövs för att fÀrdigstÀlla klient-server-koden Àr state. Kommer du ihÄg kodfragmentet frÄn avsnittet "Rendering av klienten"?

render.js

import { getCurrentState } from './state';

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

  // Gör rendering
  // ...
}

getCurrentState() mÄste kunna ge oss det aktuella spelets tillstÄnd pÄ klienten vid varje given tidpunkt baserat pÄ uppdateringar som tas emot frÄn servern. HÀr Àr ett exempel pÄ en speluppdatering som servern kan skicka:

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

Varje speluppdatering innehÄller fem identiska fÀlt:

  • t: serverns tidsstĂ€mpel som anger nĂ€r denna uppdatering skapades.
  • me: information om spelaren som tar emot denna uppdatering.
  • others: en array med information om andra spelare som deltar i samma spel.
  • bullets: en array med information om projektiler i spelet.
  • leaderboard: aktuell information om poĂ€ngtavlan. Vi kommer inte att beakta dem i detta inlĂ€gg.

7.1 Naivt klienttillstÄnd

Naiv implementering getCurrentState() kan bara direkt returnera data för den senaste mottagna speluppdateringen.

naive-state.js

let lastGameUpdate = null;

// Hantera en ny mottagen speluppdatering.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Snyggt och klart! Men om det bara vore sÄ enkelt. En av anledningarna till att en sÄdan implementering Àr problematisk Àr: den begrÀnsar renderingens bildfrekvens till serverns klockfrekvens.

Bildfrekvens (Frame Rate): antalet bilder (dvs. anrop render()) per sekund, eller FPS. I spel strÀvar man vanligtvis efter att nÄ minst 60 FPS.

Klockfrekvens (Tick Rate): frekvensen med vilken servern skickar speluppdateringar till klienterna. Den Àr ofta lÀgre Àn bildfrekvensen. I vÄrt spel fungerar servern med en takt pÄ 30 ticks per sekund.

Om vi bara renderar den senaste uppdateringen av spelet kommer FPS i princip aldrig att kunna övertrĂ€ffa 30, eftersom vi aldrig fĂ„r mer Ă€n 30 uppdateringar per sekund frĂ„n servern.. Även om vi anropar render() 60 gĂ„nger per sekund, sĂ„ kommer hĂ€lften av dessa anrop helt enkelt rendera samma innehĂ„ll och i princip inte göra nĂ„got. Ett annat problem med den naive implementeringen Ă€r att den Ă€r utsatt för fördröjningar.Vid idealisk internethastighet kommer klienten att fĂ„ speluppdateringen exakt var 33 ms (30 gĂ„nger per sekund):

Skapa ett flerspelart webspel i .io-genren
TyvÀrr Àr ingenting perfekt. En mer realistisk bild skulle vara:
Skapa ett flerspelart webspel i .io-genren
Den naive implementeringen Àr praktiskt taget det sÀmsta fallet nÀr det kommer till fördröjningar. Om speluppdateringen tas emot med en fördröjning pÄ 50 ms, sÄ försöker klienten 20 gÄnger lÀngre, eftersom den fortfarande renderar spelstatus frÄn den tidigare uppdateringen. Ni kan förestÀlla er hur obehagligt det Àr för spelaren: pÄ grund av slumpmÀssiga fördröjningar kommer spelet att verka hackigt och instabilt.

7.2 FörbÀttrad klientsituation

Vi kommer att göra vissa förbÀttringar i den naive implementeringen. För det första anvÀnder vi renderingsfördröjning pÄ 100 ms. Detta innebÀr att klientens 'aktuella' tillstÄnd alltid kommer att vara 100 ms efter serverns speltillstÄnd. Till exempel, om serverns tid Àr 150, sÄ kommer klienten att rendera ett tillstÄnd dÀr servern var vid 50:

Skapa ett flerspelart webspel i .io-genren
Detta ger oss en buffert pÄ 100 ms, vilket tillÄter oss att hantera oförutsÀgbara vÀntetider för speluppdateringar:

Skapa ett flerspelart webspel i .io-genren
Priset för detta kommer att vara en konstant inmatningsfördröjning pĂ„ 100 ms. Detta Ă€r en obetydlig uppoffring för en smidig spelupplevelse — de flesta spelare (sĂ€rskilt casuals) kommer knappt att mĂ€rka denna fördröjning. Det Ă€r mycket lĂ€ttare för mĂ€nniskor att anpassa sig till en konstant fördröjning pĂ„ 100 ms Ă€n att spela med oförutsĂ€gbara fördröjningar.

Vi kan ocksÄ anvÀnda en annan teknik som kallas "klient-side prognostisering", som hanterar att minska upplevda fördröjningar bra, men detta kommer inte att behandlas i detta inlÀgg.

En annan förbÀttring som vi anvÀnder Àr linjÀr interpolering.PÄ grund av renderingsfördröjningen ligger vi vanligtvis minst en uppdatering före den aktuella tiden pÄ klienten. NÀr vi anropar getCurrentState(), kan vi utföra linjÀr interpolering. mellan speluppdateringar direkt före och efter den aktuella tiden i klienten:

Skapa ett flerspelart webspel i .io-genren
Detta löser problemet med bildfrekvens: nu kan vi rendera unika bilder med vilken frekvens vi behöver!

7.3 Implementation av förbÀttrat klienttillstÄnd

Exempel pÄ implementation i src/client/state.js anvÀnder bÄde renderingsfördröjning och linjÀr interpolation, men det Àr övergÄende. LÄt oss dela upp koden i tvÄ delar. HÀr Àr den första:

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

  // BehÄll endast en speluppdatering före den aktuella server tiden
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Returnerar indexet för basuppdateringen, den första speluppdateringen före
// aktuell server tid, eller -1 om 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;
}

Först mÄste vi förstÄ vad currentServerTime()gör. Som vi sÄg tidigare inkluderar varje speluppdatering serverns tidsstÀmpel. Vi vill anvÀnda renderingsfördröjning för att rendera bilden, med 100 ms fördröjning frÄn servern, men vi kommer aldrig fÄ veta vad den aktuella tiden pÄ servern Àr, eftersom vi inte kan veta hur lÀnge det har tagit för oss att ta emot nÄgon av uppdateringarna. Internet Àr oförutsÀgbart och dess hastighet kan variera kraftigt!

För att komma runt detta problem kan vi anvÀnda en rimlig approximation: vi lÄtsas att den första uppdateringen kom omedelbart. Om det var sant skulle vi veta serverns tid vid det specifika tillfÀllet! Vi sparar serverns tidsstÀmpel i firstServerTimestamp och sparar vÄr lokala (klient) tidsstÀmpel vid samma ögonblick i gameStart.

Oj, vÀnta nu. Borde inte serverns tid = klientens tid? Varför skiljer vi pÄ "serverns tidsstÀmpel" och "klientens tidsstÀmpel"? Det Àr en bra frÄga! Det visar sig att det inte Àr samma sak. Date.now() kommer att ÄtervÀnda olika tidsstÀmplar i klienten och servern och detta beror pÄ lokala faktorer för dessa maskiner. Anta aldrig att tidsstÀmplarna kommer att vara lika pÄ alla maskiner.

Nu förstÄr vi vad currentServerTime()gör: den returnerar serverns tidsstÀmpel för aktuell renderingstid. Med andra ord Àr detta serverns aktuella tid (firstServerTimestamp < + (Date.now() - gameStart)) minus renderingsförseningen (RENDER_DELAY).

LÄt oss nu titta pÄ hur vi hanterar speluppdateringar. NÀr en uppdatering tas emot frÄn servern anropas onGameOver(), och vi sparar den nya uppdateringen i arrayen gameUpdates. För att kontrollera minnesanvÀndningen tar vi bort alla gamla uppdateringar till basuppdateringen, eftersom vi inte lÀngre behöver dem.

Vad Àr dÄ en "basuppdatering"? Det Àr den första uppdateringen som vi hittar nÀr vi rör oss bakÄt frÄn serverns aktuella tid. Kommer ni ihÄg detta schema?

Skapa ett flerspelart webspel i .io-genren
Speluppdateringen direkt till vÀnster om "Client Render Time" Àr basuppdateringen.

Vad anvÀnds basuppdateringen till? Varför kan vi överge uppdateringar fram till basuppdateringen? För att förstÄ detta, lÄt oss Àntligen titta pÄ implementationen 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 hanterar tre fall:

  1. base < 0 betyder att det inte finns nÄgra uppdateringar före den aktuella renderingtiden (se ovanstÄende implementation av getBaseUpdate()). Detta kan hÀnda i början av spelet pÄ grund av renderingsfördröjning. I sÄ fall anvÀnder vi den senaste mottagna uppdateringen.
  2. base Àr den senaste uppdateringen vi har. Detta kan intrÀffa pÄ grund av nÀtverksfördröjning eller dÄlig internetuppkoppling. I det fallet anvÀnder vi ocksÄ den senaste uppdateringen vi har.
  3. Vi har uppdateringar bÄde före och efter den aktuella renderingtiden, sÄ vi kan interpolera!

Allt som ÄterstÄr i state.js Àr implementationen av linjÀr interpolation, som bestÄr av enkel (men trÄkig) matematik. Om du vill studera den sjÀlv kan du öppna state.js till Github.

Del 2. Backend-server

I den hÀr delen kommer vi att titta pÄ backend Node.js som hanterar vÄrt exempel pÄ spel .io.

1. IngÄngspunkt för servern

För att hantera webbservern kommer vi att anvÀnda det populÀra webbramverket för Node.js som heter Express. Dess instÀllning kommer att hanteras av vÄr ingÄngspunkt för servern 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');

// StÀll in en Express-server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // StÀll in Webpack för utveckling
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Statisk servera mappen dist/ i produktion
  app.use(express.static('dist'));
}

// Lyssna pÄ port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server lyssnar pÄ port ${port}`);

Kom ihÄg att i den första delen diskuterade vi Webpack? HÀr kommer vi att anvÀnda vÄra Webpack-konfigurationer. Vi kommer att tillÀmpa dem pÄ tvÄ sÀtt:

  • AnvĂ€nd webpack-dev-middleware för att automatiskt ombygga vĂ„ra utvecklingspaket, eller
  • Statiskt överföra mappen dist/, dĂ€r Webpack kommer att skriva vĂ„ra filer efter produktionens byggnad.

En annan viktig uppgift server.js Àr att stÀlla in servern socket.io, som enkelt ansluter till Express-servern:

server.js, del 2

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

// StÀll in Express
// ...
const server = app.listen(port);
console.log(`Server lyssnar pÄ port ${port}`);

// StÀll in socket.io
const io = socketio(server);

// Lyssna efter socket.io-anslutningar
io.on('connection', socket => {
  console.log('Spelare ansluten!', socket.id);

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

Efter en framgÄngsrik anslutning av socket.io till servern stÀller vi in nÄgra hÀndelsehanterare för den nya socketen. HÀndelsehanterarna hanterar meddelanden som tas emot frÄn klienterna genom att delegera dem till singeltonobjektet game:

server.js, del 3

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

// ...

// StÀll in spelet
const game = new Game();

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

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

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

Vi skapar ett .io-spel, sĂ„ vi behöver bara en instans av Game ('Game') – alla spelare spelar pĂ„ en och samma arena! I nĂ€sta avsnitt kommer vi att titta pĂ„ hur denna klass fungerar. Game.

2. Game-servern

Klassen Game innehÄller den mest kritiska logiken pÄ serversidan. Den har tvÄ huvuduppgifter: hantera spelare och simulera spelet.

LĂ„t oss börja med den första uppgiften – att hantera spelare.

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;

    \/\/ Generera en position för att starta denna spelare pÄ.
    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 det hÀr spelet kommer vi att identifiera spelarna genom fÀltet id deras socket.io (om du Àr förvirrad, gÄ tillbaka till server.js). Socket.io tilldelar automatiskt varje socket en unik id, sÄ vi behöver inte oroa oss för det. Jag kommer att kalla det spelare-ID.

Kom ihÄg detta, lÄt oss utforska instansvariablerna i klassen Game:

  • sockets — detta Ă€r ett objekt som knyter spelarens ID till den socket som Ă€r kopplad till spelaren. Det gör att vi kan fĂ„ Ă„tkomst till sockets efter deras spelar-ID i konstant tid.
  • players — detta Ă€r ett objekt som binder spelarens ID till objektet code>Player

bullets — detta Ă€r en array av objekt Bullet, utan nĂ„gon specifik ordning.
lastUpdateTime — detta Ă€r en tidsstĂ€mpel för det senaste uppdateringstillfĂ€llet av spelet. Snart kommer vi att se hur den anvĂ€nds.
shouldSendUpdate — detta Ă€r en hjĂ€lpfunktion. Vi kommer ocksĂ„ att se anvĂ€ndningen av den snart.
Metoder addPlayer(), removePlayer() och handleInput() behöver ingen förklaring, de anvÀnds i server.js. Om du behöver frÀscha upp minnet, gÄ tillbaka lite.

Den sista raden constructor() startar spelets uppdateringscykel (i en frekvens av 60 uppdateringar/s):

game.js, del 2

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

class Game {
  \/\/ ...

  update() {
    \/\/ BerÀkna förfluten tid
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Uppdatera varje projektil
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Förstöra denna projektil
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

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

    \/\/ TillÀmpa kollisioner, ge spelarna poÀng för att trÀffa projektiler
    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),
    );

    \/\/ Kontrollera om nÄgon spelare Àr död
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp  {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  \/\/ ...
}

Metod update() innehÄller förmodligen den viktigaste delen av logiken pÄ serversidan. LÄt oss gÄ igenom allt det gör i ordning:

  1. BerÀknar hur mycket tid dt som har gÄtt sedan den senaste update().
  2. Uppdaterar varje projektil och förstör dem om det behövs. Vi kommer att se implementeringen av denna funktionalitet senare. För nu rÀcker det att veta att bullet.update() ÄtervÀnder true, om projektilen ska förstöras (den har lÀmnat arenan).
  3. Uppdaterar varje spelare och skapar en projektil om det behövs. Vi kommer ocksĂ„ att se denna implementering senare — player.update() kan returnera ett objekt Bullet.
  4. Kontrollerar kollisioner mellan projektiler och spelare med hjÀlp av applyCollisions(), som returnerar en array av projektiler som trÀffade spelare. För varje returnerad projektil ökar vi poÀngen för spelaren som avfyrade den (med hjÀlp av player.onDealtDamage()), och tar sedan bort projektilen frÄn arrayen bullets.
  5. Notifierar och förstör alla dödade spelare.
  6. Skickar en speluppdatering till alla spelare varannan gÄng nÀr den anropas update(). Detta hjÀlper oss att spÄra den ovan nÀmnda hjÀlpvÀrden shouldSendUpdate. Eftersom update() det kallas 60 gÄnger/s, vi skickar speluppdateringar 30 gÄnger/s. PÄ sÄ sÀtt, klockfrekvens servern Àr 30 takter/s (vi talade om klockfrekvenser i del ett).

Varför skicka speluppdateringar endast varannan gÄng ? För att spara bandbredd. 30 speluppdateringar per sekund Àr vÀldigt mycket!

Varför inte bara anropa update() 30 gÄnger i sekunden? För att förbÀttra spelens simulering. Ju oftare det anropas update(), desto mer exakt blir spelens simulering. Men man ska inte gÄ överstyr med antalet anrop, update()eftersom det Àr en berÀkningsintensiv uppgift - 60 per sekund Àr fullt tillrÀckligt.

Den resterande delen av klassen Game bestÄr av hjÀlpfunktioner som anvÀnds 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)  b.distanceTo(player)  p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() Ă€r ganska enkel – den sorterar spelare efter poĂ€ng, tar de fem bĂ€sta och returnerar anvĂ€ndarnamn och resultat för var och en.

createUpdate() anvĂ€nds i update() för att skapa speluppdateringar som skickas till spelarna. Dess huvudsakliga uppgift Ă€r att anropa metoderna serializeForUpdate(), implementerade för klasserna Player och Bullet. Observera att den endast skickar data om nĂ€ra spelare och kulor – det finns ingen anledning att skicka information om spelobjekt som ligger lĂ„ngt borta frĂ„n spelaren!

3. Spelobjekt pÄ servern

I vÄrt spel liknar kulor och spelare varandra mycket: de Àr abstrakta, runda, rörliga spelobjekt. För att dra nytta av denna likhet mellan spelare och kulor, lÄt oss börja med att implementera en grundklass 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,
    };
  }
}

HÀr hÀnder inget komplicerat. Denna klass blir en bra grund för vidareutveckling. LÄt oss se hur klassen Bullet anvÀnder 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  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Implementering Bullet Àr mycket kort! Vi har lagt till Object endast följande tillÀgg:

  • AnvĂ€ndningen av paketet shortid för att generera slumpmĂ€ssigt id projektiler.
  • TillĂ€gg av fĂ€ltet parentID, för att kunna spĂ„ra spelaren som skapade denna projektil.
  • TillĂ€gg av ett returvĂ€rde i update(), som Ă€r true, om projektilen Ă€r uteanför arenan (kom ihĂ„g att vi pratade om detta i föregĂ„ende avsnitt?).

LÄt oss gÄ vidare till 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,
    };
  }
}

Spelare Àr mer komplexa Àn projektiler, sÄ den hÀr klassen mÄste ha nÄgra fler fÀlt. Dess metod update() gör mer jobb, sÀrskilt nÀr den returnerar den nyss skapade projektilen, om det inte finns nÄgon kvar av fireCooldown (kom ihÄg att vi pratade om detta i föregÄende avsnitt?). Den utökar ocksÄ metoden serializeForUpdate(), eftersom vi behöver inkludera ytterligare fÀlt för spelaren i speluppdateringen.

Att ha en basklass Object Àr ett viktigt steg för att undvika kodupprepning. Till exempel, utan klassen Object mÄste varje spelobjekt ha samma implementation av distanceTo(), och synkroniseringen av att kopiera och klistra in alla dessa implementationer i flera filer skulle vara en mardröm. Detta blir sÀrskilt viktigt för stora projekt, nÀr antalet utvidgande Object klasser ökar.

4. Kollisiondetektering

Det enda vi har kvar Àr att kÀnna igen nÀr projektiler trÀffar spelarna! Kom ihÄg detta kodavsnitt frÄn metoden update() i klassen Game:

game.js

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

class Game {
  // ...

  update() {
    // ...

    // TillÀmpa kollisioner, ge spelare poÀng för att trÀffa projektile
    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 behöver implementera metoden applyCollisions(), som returnerar alla projektiler som trÀffar spelarna. Lyckligtvis Àr det inte sÄ svÄrt att göra, eftersom

  • Alla kolliderande objekt Ă€r cirklar, vilket Ă€r den enklaste formen för att implementera kollisioner.
  • Vi har redan en metod distanceTo(), som vi implementerade i föregĂ„ende avsnitt i klassen Object.

SÄ hÀr ser vÄr implementation av kollisionerna ut:

collisions.js

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

// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
      ) {
        destroyedBullets.push(bullet);
        player.takeBulletDamage();
        break;
      }
    }
  }
  return destroyedBullets;
}

Denna enkla kollisiondetektering Àr baserad pÄ att tvÄ cirklar kolliderar om avstÄndet mellan deras centra Àr mindre Àn summan av deras radier.HÀr Àr ett fall dÀr avstÄndet mellan centren av de tvÄ cirklarna exakt Àr summan av deras radier:

Skapa ett flerspelart webspel i .io-genren
HÀr behöver vi vara uppmÀrksamma pÄ ett par aspekter till:

  • En projektil fĂ„r inte trĂ€ffa den spelare som skapade den. Detta kan uppnĂ„s genom att jĂ€mföra bullet.parentID med player.id.
  • En projektil ska trĂ€ffa endast en gĂ„ng i ett grĂ€nsfall av samtidig kollision med flera spelare. Vi kommer att lösa denna uppgift med hjĂ€lp av operatorn break: sĂ„ snart en spelare som kolliderar med projektilen hittas, avbryter vi sökningen och gĂ„r vidare till nĂ€sta projektil.

Slut

Det var allt! Vi har gÄtt igenom allt som behövs för att skapa ett webspel av typen .io. Vad blir nÀsta steg? Bygg ditt eget .io-spel!

All kod för exemplet har öppen kÀllkod och finns pÄ Github.

KĂ€lla: habr.com

Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar đŸ”„ Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar | ProHoster