Oprettelse af et flerspiller-webspil i .io-genren

Oprettelse af et flerspiller-webspil i .io-genren
Udgivet i 2015 Agar.io blev en forælder til en ny genre af spil .io, hvis popularitet er steget betydeligt siden da. Jeg har oplevet væksten af populariteten af .io-spil: i løbet af de sidste tre år har jeg skabt og solgt to spil inden for denne genre..

Hvis du ikke har hørt om sådanne spil før: det er gratis multiplayer-webspil, som er lette at deltage i (ingen konto kræves). De har normalt mange modstridende spillere i en arena. Andre berømte spil i .io-genren: Slither.io og Diep.io.

I dette indlæg vil vi se på, hvordan man kan lave et .io-spil fra bunden.For dette er det nok at have kendskab til Javascript: du skal forstå koncepter som syntaks ES6, nøgleordet dette og Promises. Selvom du ikke er en mester i Javascript, vil du stadig kunne forstå det meste af indlægget.

Eksempel på et .io-spil

For at hjælpe med læringen vil vi henvise til eksemplet på et .io-spil.Prøv at spille det!

Oprettelse af et flerspiller-webspil i .io-genren
Spillet er ret simpelt: du styrer et skib på en arena, hvor der er andre spillere. Dit skib skyder automatisk projektiler, og du prøver at ramme de andre spillere, samtidig med at du undgår deres projektiler.

1. Kort oversigt/struktur af projektet

Jeg anbefaler at downloade kildekoden til eksemplet på spillet, så du kan følge med mig.

I eksemplet anvendes følgende:

  • Express — den mest populære web-ramme for Node.js, der driver spillets webserver.
  • socket.io — websocket-bibliotek til dataudveksling mellem browser og server.
  • Webpack — modulhåndtering. Du kan læse om, hvorfor man skal bruge Webpack her.

Sådan ser projektkatalogstrukturen ud:

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

public/

Alt i mappen public/ vil blive statisk leveret af serveren. I public/assets/ er de billeder, der bruges af vores projekt, gemt.

src/

Al kildekoden findes i mappen src/. Navnene client/ og server/ taler for sig selv, og shared/ indeholder en konstantfil, der importeres både af klienten og serveren.

2. Bygning/parameter for projektet

Som nævnt tidligere bruger vi modulhåndtereren til at bygge projektet Webpack. Lad os tage et kig på vores 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 vigtigste linjer her er følgende:

  • src/client/index.js — dette er indgangspunktet for Javascript-klienten (JS). Webpack vil starte herfra og søge rekursivt efter andre importerede filer.
  • Den udgående JS fra vores Webpack-bygning vil være placeret i mappen dist/. Jeg vil kalde denne fil vores JS-pakke.
  • Vi bruger Babel, og specifikt konfigurationen @babel/preset-env til transpiling af vores JS-kode til ældre browsere.
  • Vi bruger et plugin til at udtrække al CSS, der refereres i JS-filerne, og til at samle dem ét sted. Jeg vil kalde dette vores CSS-pakke.

Du har måske bemærket de mærkelige filnavne på pakkerne '[name].[contenthash].ext'. De indeholder inputnavne Webpack: [name] vil blive erstattet med navnet på indgangspunktet (i vores tilfælde er det game), mens [contenthash] vil blive erstattet med en hash af filens indhold. Vi gør dette for at optimere projektet til hashing — vi kan bede browsere om konstant at cache vores JS-pakker, fordi hvis pakken ændres, ændres også filens navn (ændrer contenthash). Det færdige resultat vil være et filnavn af typen game.dbeee76e91a97d0c7207.js.

Fil webpack.common.js — det er den grundlæggende konfigurationsfil, som vi importerer i udviklings- og produktionskonfigurationer. Her er for eksempel udviklingskonfigurationen:

webpack.dev.js

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

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

For effektivitet bruger vi webpack.dev.jsunder udviklingsprocessen, og skifter til webpack.prod.jsfor at optimere størrelsen af pakkerne ved implementering til produktion.

Lokal opsætning

Jeg anbefaler at installere projektet på din lokale maskine, så du kan følge de trin, der er beskrevet i dette indlæg. Opsætningen er enkel: for det første skal Node og NPMvære installeret i systemet.

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

er du klar til at komme i gang! For at starte udviklingsserveren skal du blot køre

$ npm run develop

og åbne i webbrowseren på localhost:3000. Udviklingsserveren vil automatisk genkompilere JS- og CSS-pakkerne, når koden ændres — opdater blot siden for at se alle ændringerne!

3. Klientpunkter

Lad os komme til selve spillets kode. Først har vi brug for en side index.html, som vil blive den første, der indlæses, når siden besøges. Vores side vil være ret simpel:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Et eksempel på et .io-spil</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="Brugernavn" />
    <button id="play-button">SPIL</button>
  </div>
</body>
</html>

Dette kodeeksempel er forenklet for at være lettere at forstå, og jeg vil gøre det samme med mange andre eksempler i indlægget. Den fulde kode kan altid ses på Github.

Vi har:

  • HTML5 Canvas-elementet (<canvas>), som vi vil bruge til at gengive spillet.
  • <link> for at tilføje vores CSS-pakke.
  • <script> for at tilføje vores Javascript-pakke.
  • Hovedmenu med brugernavn <input> og en 'PLAY'-knap (

Når hjemmesiden er indlæst i browseren, vil Javascript-koden begynde at køre fra indgangspunktets JS-fil: 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);
  };
});

Det kan se kompliceret ud, men der sker faktisk ikke så meget her:

  1. Import af flere andre JS-filer.
  2. Import af CSS (så Webpack ved, at den skal inkludere dem i vores CSS-pakke).
  3. Kørsel connect() for at oprette en forbindelse til serveren og starte downloadAssets() for at downloade de billeder, der er nødvendige for at gengive spillet.
  4. Når trin 3 er færdig vises hovedmenuen (playMenu).
  5. Indstil en håndtering til knappen 'PLAY'. Når knappen trykkes, initialiserer koden spillet og informerer serveren om, at vi er klar til at spille.

Det primære 'kød' i vores klient-server-logik findes i de filer, som er blevet importeret af filen index.js. Nu vil vi gennemgå dem alle én efter én.

4. Dataudveksling mellem klient og server

I dette spil bruger vi et velkendt bibliotek til at kommunikere med serveren. socket.io. I Socket.io har indbygget support WebSockets, som er godt egnet til tovejskommunikation: vi kan sende beskeder til serveren og serveren kan sende beskeder tilbage til os over den samme forbindelse.

Vi får en fil src/client/networking.js, der håndterer alle kommunikationer 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('Forbundet til server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Registrer 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 kode er også forkortet for klarhedens skyld.

I denne fil sker der tre hovedhandlinger:

  • Vi prøver at forbinde til serveren. connectedPromise bliver kun opfyldt, når vi har oprettet forbindelse.
  • Hvis forbindelsen er lykkedes, registrerer vi callback-funktioner (processGameUpdate() og onGameOver()) for beskeder, vi kan modtage fra serveren.
  • Vi eksporterer play() og updateDirection(), så de kan bruges af andre filer.

5. Rendering af klienten

Det er tid til at vise billedet på skærmen!

…men før vi kan gøre det, skal vi hente alle billederne (ressourcerne), der er nødvendige. Lad os skrive en ressourcehåndterer:

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(`Downloadet ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

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

At håndtere ressourcer er ikke så svært! Hovedideen er at opbevare et objekt assets, som vil forbinde filnavnets nøgle til objektets værdi Billede. Når ressourcen er blevet indlæst, gemmer vi den i objektet assets for hurtig adgang i fremtiden. Når hver enkelt ressourcemulighed er opfyldt (det vil sige ressourcerne er indlæst), opfylder vi alle downloadPromise Når ressourcerne er hentet, kan vi begynde at visualisere. Som nævnt tidligere bruger vi.

HTML5 Canvas ). Vores spil er ret simpelt, så vi behøver kun at tegne det følgende: (<canvas>). Vores spil er ret enkelt, så vi behøver kun at tegne det følgende:

  1. Baggrund
  2. Spillerens skib
  3. Andre spillere, der er i spillet
  4. Projektile

Her er vigtige fragmenter src/client/render.js, som explicit viser de nævnte fire punkter:

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;

// Få canvas grafik konteksten
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Gør canvas fuldskærm
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Tegn baggrund
  renderBackground(me.x, me.y);

  // Tegn alle projektile
  bullets.forEach(renderBullet.bind(null, me));

  // Tegn alle spillere
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Hjalpefunktioner her udeladt

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

Denne kode er også forkortet for overskuelighed.

render() — hovedfunktionen i denne fil. startRendering() og stopRendering() styrer aktiveringen af renderingscyklussen med en frekvens på 60 FPS.

De specifikke implementeringer af de enkelte hjælpfunktioner til rendering (for eksempel renderBullet()) er ikke så vigtige, men her er et simpelt 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,
  );
}

Bemærk, at vi bruger metoden getAsset(), som vi så tidligere i asset.js!

Hvis du er interesseret i at lære om flere hjælpfunktioner til rendering, så læs resten af src/client/render.js.

6. Klientinput

Det er tid til at gøre spillet spillbart! Kontrolskemaet vil være meget simpelt: til at ændre bevægelsesretningen kan du bruge musen (på computer) eller berøring af skærmen (på mobile enheder). For at implementere dette vil vi registrere Event Listeners for Mouse- og Touch-begivenheder.
Det hele vil blive håndteret af 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() og onTouchInput() — disse Event Listeners, der kalder updateDirection() (fra networking.js) ved en input-begivenhed (for eksempel ved at flytte musen). updateDirection() kommunikerer med serveren, der håndterer inputbegivenheder og opdaterer spillets tilstand i overensstemmelse hermed.

7. Klientens tilstand

Denne sektion er den mest komplekse i første del af indlægget. Bliv ikke frustreret, hvis du ikke forstår det første gang! Du kan endda springe over og komme tilbage til det senere.

Den sidste brik i puslespillet, der er nødvendig for at afslutte klient-server-koden, er state. Husk kodefragmentet fra afsnittet "Klient rendering"?

render.js

import { getCurrentState } from './state';

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

  // Udfør rendering
  // ...
}

getCurrentState() skal være i stand til at give os den nuværende spiltilstand på klienten på ethvert tidspunkt baseret på opdateringer modtaget fra serveren. Her er et eksempel på en spilopdatering, 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 spilopdatering indeholder fem ens felter:

  • t: tidsstempel fra serveren, der angiver hvornår denne opdatering blev oprettet.
  • me: information om spilleren, der modtager denne opdatering.
  • others: et array med information om andre spillere, der deltager i det samme spil.
  • bullets: et array med information om projektiler i spillet.
  • leaderboard: de aktuelle data i leaderboardet. I dette indlæg vil vi ikke tage dem i betragtning.

7.1 Naiv klienttilstand

Naiv implementering getCurrentState() kan kun direkte returnere dataene fra den senest modtagne spilopdatering.

naive-state.js

let lastGameUpdate = null;

// Håndter en ny modtaget spilopdatering.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Flot og klart! Men hvis kun alt var så simpelt. En af grundene til, at en sådan implementering er problematisk: den begrænser renderingens billedhastighed til serverens taktfrekvens.

Billedhastighed (Frame Rate): antallet af billeder (dvs. kald render()) pr. sekund, eller FPS. I spil stræber man generelt efter at opnå mindst 60 FPS.

Taktfrekvens (Tick Rate): frekvensen hvormed serveren sender spilopdateringer til klienterne. Ofte er den lavere end billedhastigheden. I vores spil fungerer serveren ved 30 takts pr. sekund.

Hvis vi bare render det seneste spilopdatering, vil FPS i bund og grund aldrig kunne overstige 30, fordi vi aldrig modtager mere end 30 opdateringer fra serveren per sekund. Selvom vi kalder render() 60 gange i sekundet, vil halvdelen af disse opkald blot rendere det samme, hvilket i princippet ikke gør noget. Et andet problem med naive implementeringer er, at de er udsat for forsinkelser. Ved perfekt internetforbindelse vil klienten modtage spilopdateringen præcis hver 33 ms (30 i sekundet):

Oprettelse af et flerspiller-webspil i .io-genren
Desværre er intet perfekt. Et mere realistisk billede ville være:
Oprettelse af et flerspiller-webspil i .io-genren
Den naive implementering er praktisk talt det værst tænkelige tilfælde, når det kommer til forsinkelser. Hvis spilopdateringen modtages med en forsinkelse på 50 ms, så bliver klienten forsinket med ekstra 50 ms, fordi den stadig renderer spiltilstanden fra den tidligere opdatering. Du kan forestille dig, hvor ubehageligt det er for spilleren: På grund af tilfældige forsinkelser vil spillet fremstå hakkende og ustabilt.

7.2 Forbedret klienttilstand

Vi vil foretage nogle forbedringer i den naive implementering. For det første bruger vi renderingsforsinkelse på 100 ms. Det betyder, at den 'nuværende' tilstand af klienten altid vil være 100 ms bag serverens spiltilstand. For eksempel, hvis tiden på serveren er 150, så vil klienten rendere tilstanden, som serveren var i under 50:

Oprettelse af et flerspiller-webspil i .io-genren
Dette giver os et buffer på 100 ms, som gør os i stand til at håndtere den uforudsigelige tid for at modtage spilopdateringer:

Oprettelse af et flerspiller-webspil i .io-genren
Prisen for dette vil være en konstant input lag på 100 ms. Dette er en ubetydelig ofring for en glidende spilleoplevelse — de fleste spillere (især casual spillere) vil endda ikke lægge mærke til denne forsinkelse. Det er meget lettere for folk at tilpasse sig en konstant forsinkelse på 100 ms end at spille med uforudsigelig forsinkelse.

Vi kan også bruge en anden teknik kaldet 'klientside forudsigelse', som fungerer godt til at reducere oplevede forsinkelser, men vil ikke blive behandlet i dette indlæg.

En anden forbedring, vi bruger, er lineær interpolation. På grund af renderingsforsinkelsen overhaler vi normalt den aktuelle tid på klienten med mindst en opdatering. Når der kaldes getCurrentState(), kan vi udføre lineær interpolation mellem opdateringerne af spillet lige før og efter det nuværende tidspunkt i klienten:

Oprettelse af et flerspiller-webspil i .io-genren
Dette løser problemet med billedfrekvens: nu kan vi gengive unikke billeder med enhver ønsket frekvens!

7.3 Implementering af forbedret klienttilstand

Eksempel på implementering i src/client/state.js bruger både gengivelsesforsinkelse og lineær interpolation, men det er kun midlertidigt. Lad os dele koden op i to dele. 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);

  // Behold kun én spilopdatering før det aktuelle servertidspunkt
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

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

// Returnerer indekset for basisopdateringen, den første spilopdatering før
// aktuelt servertidspunkt, eller -1 hvis 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 skal vi forstå, hvad currentServerTime()gør. Som vi så før, inkluderer hver spilopdatering en server-tidsstempel. Vi ønsker at bruge gengivelsesforsinkelsen til at gengive billedet med en forsinkelse fra serveren på 100 ms, men vi vil aldrig finde ud af, hvad den aktuelle tid på serveren er, fordi vi ikke kan vide, hvor længe det har taget for nogen af opdateringerne at nå os. Internettet er uforudsigeligt, og hastigheden kan variere meget!

For at omgå dette problem kan vi bruge en fornuftig tilnærmelse: vi låner os til at sige, at den første opdatering ankom øjeblikkeligt. Hvis det var sandt, ville vi vide serverens tid på dette specifikke tidspunkt! Vi gemmer serverens tidsstempel i firstServerTimestamp og gemmer vores lokale (klient) tidsstempel i det samme øjeblik i gameStart.

Åh, vent lidt. Skal ikke serverens tid = klientens tid? Hvorfor skelner vi mellem "server tidsstempel" og "klient tidsstempel"? Det er et godt spørgsmål! Det viser sig, at det ikke er det samme. Date.now() vil returnere forskellige tidsstempler i klienten og serveren, og det afhænger af lokale faktorer for disse maskiner. Antag aldrig, at tidsstemplerne er de samme på alle maskiner.

Nu forstår vi, hvad currentServerTime()gør: det returnerer serverens tidsstempel for den aktuelle gengivelsestid. Med andre ord, det er serverens aktuelle tid (firstServerTimestamp <+(Date.now() - gameStart)) minus renderingsforsinkelsen (RENDER_DELAY).

. Lad os nu se på, hvordan vi behandler spilopdateringer. Når vi modtager en opdatering fra serveren, kaldes processGameUpdate(), og vi gemmer den nye opdatering i arrayet gameUpdates. Derefter, for at kontrollere hukommelsesforbruget, fjerner vi alle gamle opdateringer til den grundlæggende opdatering, da vi ikke længere har brug for dem.

Hvad er en «grundlæggende opdatering»? Det er den første opdatering, vi finder, når vi bevæger os baglæns fra serverens aktuelle tid. Kan du huske denne skema?

Oprettelse af et flerspiller-webspil i .io-genren
Spilopdateringen lige til venstre for «Client Render Time» er den grundlæggende opdatering.

Hvad bruges den grundlæggende opdatering til? Hvorfor kan vi kassere opdateringer til den grundlæggende? For at forstå dette, lad os endelig 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 tilfælde:

  1. base < 0 betyder, at der ikke er nogen opdateringer før den aktuelle renderingtid (se ovenstående implementering getBaseUpdate()). Dette kan ske i starten af spillet på grund af renderingsforsinkelse. I så fald bruger vi den seneste modtagne opdatering.
  2. base — det er den seneste opdatering, vi har. Dette kan ske på grund af netværksforsinkelser eller dårlig internetforbindelse. I dette tilfælde bruger vi også den seneste opdatering, vi har.
  3. Vi har en opdatering både før og efter den aktuelle renderingtid, så vi kan interpolere!

Alt, hvad der er tilbage i state.js — er implementeringen af lineær interpolation, som består af enkel (men kedelig) matematik. Hvis du ønsker at studere det selv, så åbn state.js og få en fungerende kommando. Github.

Del 2. Backend-server

I denne del vil vi se på backend Node.js, der styrer vores eksempel på .io-spil.

1. Indgangspunktet for serveren

For at styre webserveren vil vi bruge det populære webframework for Node.js kaldet Express. Dets opsætning vil blive håndteret af vores indgangspunkt serverfil 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');

// Opsæt en Express-server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Opsæt Webpack til udvikling
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Statisk servere dist/-mappen i produktion
  app.use(express.static('dist'));
}

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

Husk, at i første del diskuterede vi Webpack? Det er her, vi vil bruge vores Webpack-konfigurationer. Vi vil anvende dem på to måder:

  • Brug webpack-dev-middleware til automatisk genopbygning af vores udviklingspakker, eller
  • Statiske overførsel af mappen dist/, hvor Webpack vil skrive vores filer efter produktsammensætningen.

En anden vigtig opgave server.js består i at konfigurere serveren socket.io, som blot opretter forbindelse til Express-serveren:

server.js, del 2

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

// Opsæt Express
// ...
const server = app.listen(port);
console.log(`Server lytter på port ${port}`);

// Opsæt socket.io
const io = socketio(server);

// Lyt efter socket.io-forbindelser
io.on('connection', socket => {
  console.log('Spiller tilsluttet!', socket.id);

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

Efter en vellykket tilslutning af socket.io til serveren opsætter vi begivenhedsbehandlere for den nye socket. Begivenhedsbehandlerne behandler beskeder modtaget fra klienterne ved at delegere til singleton-objektet game:

server.js, del 3

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

// ...

// Opsæt spillet
const game = new Game();

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

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

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

Vi laver et .io-spil, så vi har kun brug for én instans Game («Game») – alle spillere spiller på samme arena! I næste afsnit vil vi se, hvordan denne klasse fungerer Game.

2. Game-serveren

Klasse Game indeholder den vigtigste logik på serversiden. Den har to hovedopgaver: håndtering af spillere og spilsimulation.

Lad os begynde med den første opgave – håndtering af spillere.

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;

    // Generer en position til at starte denne spiller.
    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 spil vil vi identificere spillere ved hjælp af feltet id deres socket.io (hvis du er forvirret, så gå tilbage til server.js). Socket.io tildeler automatisk hver socket en unik id, så vi behøver ikke bekymre os om dette. Jeg vil referere til det som Spiller-ID.

Når vi har husket dette, lad os se på instansvariablerne i klassen Game:

  • sockets — dette er et objekt, der binder spiller-ID til den socket, der er knyttet til spilleren. Det gør det muligt for os at få adgang til sockets baseret på deres spiller-ID'er på konstant tid.
  • players — dette er et objekt, der binder spiller-ID til objektet code>Player

bullets — dette er et array af objekter Bullet, som ikke har nogen bestemt rækkefølge.
lastUpdateTime — dette er et tidsstempel for den sidste opdatering af spillet. Snart vil vi se, hvordan det bruges.
shouldSendUpdate — dette er en hjælpevariabel. Dens brug vil vi også se snart.
Metoder addPlayer(), removePlayer() og handleInput() behoves ikke at blive forklaret, de bruges i server.js. Hvis du har brug for at friske din hukommelse, så gå lidt op.

Den sidste linje constructor() starter spilopdateringscyklussen med en hastighed på 60 opdateringer/s:

game.js, del 2

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

class Game {
  \/\/ ...

  update() {
    \/\/ Beregn den tid, der er gået
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Opdater hver projektil
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Ødelæg denne projektil
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

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

    \/\/ Anvend kollisioner, giv spillerne point for at ramme 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),
    );

    \/\/ Tjek om der er død
    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 en gameplay-opdatering til hver spiller hver anden gang
    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;
    }
  }

  \/\/ ...
}

Metoden update() indeholder nok den vigtigste del af logikken på serversiden. Lad os gennemgå, hvad den gør:

  1. Beregn, hvor lang tid dt der er gået siden sidst update().
  2. Opdaterer hvert projektiv og ødelægger dem om nødvendigt. Vi vil se implementeringen af denne funktionalitet senere. Indtil videre er det nok at vide, at bullet.update() returnerer true, hvis projektilen skal ødelægges (den er gået ud over arenaen).
  3. Opdaterer hver spiller og opretter et projektiv hvis nødvendigt. Vi vil også se denne implementering senere — player.update() kan returnere et objekt Bullet.
  4. Tjekker kollisioner mellem projektiver og spillere ved hjælp af applyCollisions(), som returnerer et array af projektiver, der ramte spillerne. For hver returneret projektil giver vi spilleren, der affyrede det, point (ved hjælp af player.onDealtDamage()), og derefter fjerner vi projektilen fra arrayet bullets.
  5. Underretter og fjerner alle dræbte spillere.
  6. Sender en opdatering af spillet til alle spillere hver anden gang ved kaldet update(). Dette hjælper os med at spore den ovenfor nævnte hjælpevariabel shouldSendUpdate. Sådan update() det kaldt 60 gange/s, vi sender opdateringer til spillet 30 gange/s. Sådan er det, klokfrekvens serveren er 30 takts/s (vi talte om klokfrekvens i den første del).

Hvorfor sende opdateringer til spillet kun hver anden gang? ? For at spare båndbredden. 30 opdateringer til spillet i sekundet – det er rigtig meget!

Hvorfor så ikke bare kalde update() 30 gange i sekundet? For at forbedre simuleringen af spillet. Jo oftere det kaldes update(), jo mere præcis vil simuleringen være. Men man skal ikke overdrive antallet af opkald, update()for det er en beregningsmæssigt krævende opgave – 60 i sekundet er helt tilstrækkeligt.

Den resterende del af klassen Game består af hjælpe metoder, der bruges 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() er ganske enkel – den sorterer spillerne efter score, tager de fem bedste og returnerer brugernavn og score for hver.

createUpdate() bruges i update() til at skabe opdateringer af spillet, som sendes til spillerne. Dens hovedopgave er at kalde metoderne serializeForUpdate(), implementeret for klasserne Player og Bullet. Vær opmærksom på, at den kun sender data om nærliggende spillere og projektiler – der er ikke behov for at sende oplysninger om spilobjekter, der er langt fra spilleren!

3. Spilobjekter på serveren

I vores spil er projektiler og spillere faktisk meget ens: de er abstrakte, runde, bevægelige spilobjekter. For at udnytte denne lighed mellem spillere og projektiler, lad os starte med at implementere en grundlæggende klasse Object:

object.js

class Object {
  constructor(id, x, y, dir, speed) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.direction = dir;
    this.speed = speed;
  }

  update(dt) {
    this.x += dt * this.speed * Math.sin(this.direction);
    this.y -= dt * this.speed * Math.cos(this.direction);
  }

  distanceTo(object) {
    const dx = this.x - object.x;
    const dy = this.y - object.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  setDirection(dir) {
    this.direction = dir;
  }

  serializeForUpdate() {
    return {
      id: this.id,
      x: this.x,
      y: this.y,
    };
  }
}

Her sker der ikke noget kompliceret. Denne klasse vil være et godt udgangspunkt for udvidelser. Lad os se, hvordan klassen Bullet bruger 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 er meget kort! Vi har tilføjet Object kun følgende udvidelser:

  • Brug af pakken shortid til tilfældig generation af id projektiler.
  • Tilføjelse af feltet parentID, så vi kan spore spilleren, der skabte dette projektil.
  • Tilføjelse af returværdien til update(), som er true, hvis projektilen er uden for arenaen (husker du, vi talte om dette i det tidligere afsnit?).

Lad os 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 mere komplekse end projektiler, så denne klasse skal gemme et par ekstra felter. Dens metode update() gør et større stykke arbejde, især ved at returnere det nyoprettede projektil, hvis der ikke er tilbage fireCooldown (husker du, vi talte om dette i det tidligere afsnit?). Den udvider også metoden serializeForUpdate(), fordi vi skal inkludere yderligere felter til spilleren i opdateringen af spillet.

At have en grundlæggende klasse Object er et vigtigt skridt for at undgå kode gentagelse. For eksempel uden klassen Object skulle hver spilelement have en identisk implementering af distanceTo(), og synkronisering af kopi-paste af alle disse implementeringer i flere filer ville være et mareridt. Dette bliver særligt vigtigt for større projekter, når antallet af udvidende Object klasser stiger.

4. Kollision genkendelse

Det eneste, vi har tilbage til at gøre, er at genkende, når projektiler rammer spillerne! Husk denne kodefragment fra metoden update() i klassen Game:

game.js

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

class Game {
  // ...

  update() {
    // ...

    // Anvend kollisioner, giv spillerne point for at ramme 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),
    );

    // ...
  }
}

Vi skal implementere metoden applyCollisions(), der returnerer alle projektiler, der rammer spillerne. Heldigvis er det ikke så svært at gøre, fordi

  • Alle kolliderende objekter er cirkler, hvilket gør genkendelse af kollisioner til en enkel figur at implementere.
  • Vi har allerede metoden distanceTo(), som vi implementerede i den forrige sektion i klassen Object.

Her er, hvordan vores implementering af kollision genkendelse ser ud:

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

Denne enkle kollision genkendelse er baseret på det faktum, at to cirkler kolliderer, hvis afstanden mellem deres centre er mindre end summen af deres radier.Her er et tilfælde, hvor afstanden mellem centrene af to cirkler præcist er lig med summen af deres radier:

Oprettelse af et flerspiller-webspil i .io-genren
Her skal der tages hensyn til et par aspekter:

  • Et projektil må ikke ramme den spiller, der skabte det. Dette kan opnås ved at sammenligne bullet.parentID med player.id.
  • Et projektil skal kun ramme én gang i tilfælde af simultan kollision med flere spillere. Dette problem løser vi med operatoren break: så snart vi finder en spiller, der kolliderer med projektilen, stopper vi søgningen og går videre til det næste projektil.

Slut

Det var det! Vi har dækket alt, hvad du behøver at vide for at skabe et .io-webspil. Hvad er næste skridt? Byg dit eget .io-spil!

Al koden fra eksemplet har åbne kilder og er tilgængelig på Github.

Kilde: habr.com

Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere 🔥 Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere | ProHoster