Oprettelse af et multiplayer .io-webspil

Oprettelse af et multiplayer .io-webspil
Udgivet i 2015 Agar.io blev stamfader til en ny genre spil .iosom er vokset i popularitet siden da. Jeg har personligt oplevet stigningen i popularitet af .io-spil: I løbet af de sidste tre år har jeg skabt og solgt to spil af denne genre..

Hvis du aldrig har hørt om disse spil før, er disse gratis multiplayer-webspil, der er nemme at spille (ingen konto påkrævet). De møder normalt mange modstridende spillere i samme arena. Andre berømte .io-spil: Slither.io и Diep.io.

I dette indlæg vil vi undersøge hvordan opret et .io-spil fra bunden. Til dette vil kun viden om Javascript være nok: du skal forstå ting som syntaks ES6, nøgleord this и Promises. Selvom din viden om Javascript ikke er perfekt, kan du stadig forstå det meste af indlægget.

.io spil eksempel

For læringshjælp vil vi henvise til .io spil eksempel. Prøv at spille det!

Oprettelse af et multiplayer .io-webspil
Spillet er ret simpelt: du styrer et skib i en arena, hvor der er andre spillere. Dit skib affyrer automatisk projektiler, og du forsøger at ramme andre spillere, mens du undgår deres projektiler.

1. Kort oversigt / struktur af projektet

anbefale download kildekode eksempel spil, så du kan følge mig.

Eksemplet bruger følgende:

  • Express er det mest populære Node.js-webframework, der administrerer spillets webserver.
  • socket.io - et websocket-bibliotek til udveksling af data mellem en browser og en server.
  • webpack - modulansvarlig. Du kan læse om, hvorfor du bruger Webpack. her.

Sådan ser projektkatalogstrukturen ud:

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

offentlig/

Alt i en mappe public/ vil blive indsendt statisk af serveren. I public/assets/ indeholder billeder brugt af vores projekt.

src /

Al kildekode er i mappen src/. Titler client/ и server/ tale for sig selv og shared/ indeholder en konstant fil, der importeres af både klienten og serveren.

2. Samlinger/projektindstillinger

Som nævnt ovenfor bruger vi modullederen 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:

  • src/client/index.js er indgangspunktet for Javascript (JS) klienten. Webpack vil starte herfra og søge rekursivt efter andre importerede filer.
  • Output-JS fra vores Webpack-build vil være placeret i mappen dist/. Jeg vil kalde denne fil vores js-pakken.
  • Vi bruger Babel, og især konfigurationen @babel/preset-env at transpilere vores JS-kode til ældre browsere.
  • Vi bruger et plugin til at udtrække al den CSS, som JS-filerne refererer til, og kombinere dem ét sted. Jeg vil kalde ham vores css-pakke.

Du har muligvis bemærket mærkelige pakkefilnavne '[name].[contenthash].ext'. De indeholder filnavnserstatninger webpack: [name] vil blive erstattet med navnet på inputpunktet (i vores tilfælde dette game), og [contenthash] vil blive erstattet med en hash af filens indhold. Vi gør det for at optimere projektet til hashing - du kan bede browsere om at cache vores JS-pakker på ubestemt tid, pga hvis en pakke ændres, ændres dens filnavn også (ændringer contenthash). Det endelige resultat vil være navnet på visningsfilen game.dbeee76e91a97d0c7207.js.

fil webpack.common.js er basiskonfigurationsfilen, som vi importerer til udviklings- og færdige projektkonfigurationer. Her er et eksempel på udviklingskonfiguration:

webpack.dev.js

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

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

For effektivitet bruger vi i udviklingsprocessen webpack.dev.js, og skifter til webpack.prod.jsfor at optimere pakkestørrelser ved implementering til produktion.

Lokale omgivelser

Jeg anbefaler at installere projektet på en lokal maskine, så du kan følge de trin, der er angivet i dette indlæg. Opsætningen er enkel: For det første skal systemet være installeret Node и NPM. Dernæst skal du gøre

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

og du er klar til at gå! For at starte udviklingsserveren skal du bare køre

$ npm run develop

og gå til webbrowser localhost: 3000. Udviklingsserveren vil automatisk genopbygge JS- og CSS-pakkerne, efterhånden som koden ændres - bare opdater siden for at se alle ændringerne!

3. Klientindgangspunkter

Lad os komme ned til selve spilkoden. Først skal vi have en side index.html, når du besøger webstedet, vil browseren indlæse det først. Vores side vil være ret simpel:

index.html

Et eksempel på .io spil  SPIL

Dette kodeeksempel er blevet forenklet lidt for klarhedens skyld, og jeg vil gøre det samme med mange af de andre posteksempler. Den fulde kode kan altid ses på Github.

Vi har:

  • HTML5 lærredselement (<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 PLAY-knappen (<button>).

Efter indlæsning af hjemmesiden, vil browseren begynde at udføre Javascript-kode, startende fra indgangspunktet JS-filen: src/client/index.js.

index.js

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Det lyder måske kompliceret, men der sker ikke meget her:

  1. Import af flere andre JS-filer.
  2. CSS-import (så Webpack ved at inkludere dem i vores CSS-pakke).
  3. Запуск connect() at oprette forbindelse til serveren og køre downloadAssets() at downloade billeder, der er nødvendige for at gengive spillet.
  4. Efter afslutning af trin 3 hovedmenuen vises (playMenu).
  5. Indstilling af handleren til at trykke på knappen "PLAY". Når der trykkes på knappen, initialiserer koden spillet og fortæller serveren, at vi er klar til at spille.

Det vigtigste "kød" af vores klient-server-logik er i de filer, der blev importeret af filen index.js. Nu vil vi overveje dem alle i rækkefølge.

4. Udveksling af kundedata

I dette spil bruger vi et velkendt bibliotek til at kommunikere med serveren socket.io. Socket.io har indbygget support WebSockets, som er velegnede til tovejskommunikation: vi kan sende beskeder til serveren и serveren kan sende beskeder til os på samme forbindelse.

Vi vil have én fil src/client/networking.jshvem skal tage sig af alle kommunikation med serveren:

netværk.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 kode er også blevet forkortet lidt for klarhedens skyld.

Der er tre hovedhandlinger i denne fil:

  • Vi forsøger at oprette forbindelse til serveren. connectedPromise kun tilladt, når vi har etableret en forbindelse.
  • Hvis forbindelsen lykkes, registrerer vi tilbagekaldsfunktioner (processGameUpdate() и onGameOver()) for beskeder, vi kan modtage fra serveren.
  • Vi eksporterer play() и updateDirection()så andre filer kan bruge dem.

5. Klientgengivelse

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

…men før vi kan gøre det, skal vi downloade alle de billeder (ressourcer), der er nødvendige for dette. Lad os skrive en ressourcemanager:

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

Ressourcestyring er ikke så svært at implementere! Hovedideen er at opbevare en genstand assets, som vil binde nøglen til filnavnet til værdien af ​​objektet Image. Når ressourcen er indlæst, gemmer vi den i et objekt assets for hurtig adgang i fremtiden. Hvornår får hver enkelt ressource lov til at downloade (dvs. alle ressourcer), vi tillader downloadPromise.

Når du har downloadet ressourcerne, kan du begynde at gengive. Som sagt tidligere, for at tegne på en webside, bruger vi HTML5 lærred (<canvas>). Vores spil er ret simpelt, så vi behøver kun at tegne følgende:

  1. baggrund
  2. Spillerskib
  3. Andre spillere i spillet
  4. ammunition

Her er de vigtige uddrag src/client/render.js, som gengiver nøjagtigt de fire elementer, der er angivet 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 kode er også forkortet for klarhedens skyld.

render() er hovedfunktionen af ​​denne fil. startRendering() и stopRendering() styre aktiveringen af ​​gengivelsesløkken ved 60 FPS.

Konkrete implementeringer af individuelle gengivelseshjælpefunktioner (f.eks. renderBullet()) er ikke så vigtige, 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,
  );
}

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

Hvis du er interesseret i at lære om andre renderingshjælpere, så læs resten. src/client/render.js.

6. Klient input

Det er tid til at lave et spil spilbare! Kontrolskemaet vil være meget enkelt: For at ændre bevægelsesretningen kan du bruge musen (på en computer) eller trykke på skærmen (på en mobilenhed). For at implementere dette, vil vi registrere Begivenhedslyttere til Mouse and Touch-begivenheder.
Vil tage sig af alt dette src/client/input.js:

input.js

import { updateDirection } from './networking';

function onMouseInput(e) {
  handleInput(e.clientX, e.clientY);
}

function onTouchInput(e) {
  const touch = e.touches[0];
  handleInput(touch.clientX, touch.clientY);
}

function handleInput(x, y) {
  const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
  updateDirection(dir);
}

export function startCapturingInput() {
  window.addEventListener('mousemove', onMouseInput);
  window.addEventListener('touchmove', onTouchInput);
}

export function stopCapturingInput() {
  window.removeEventListener('mousemove', onMouseInput);
  window.removeEventListener('touchmove', onTouchInput);
}

onMouseInput() и onTouchInput() er begivenhedslyttere, der ringer updateDirection() (af networking.js) når en inputhændelse opstår (for eksempel når musen flyttes). updateDirection() håndterer meddelelser med serveren, som håndterer inputhændelsen og opdaterer spillets tilstand i overensstemmelse hermed.

7. Klientstatus

Dette afsnit er det sværeste i første del af indlægget. Vær ikke modløs, hvis du ikke forstår det første gang, du læser det! Du kan endda springe det over og vende tilbage til det senere.

Den sidste brik i puslespillet, der skal til for at fuldføre klient-/serverkoden, er tilstand. Kan du huske kodestykket fra sektionen Client Rendering?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() skal kunne give os den aktuelle tilstand af spillet i 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 identiske felter:

  • t: Servertidsstempel, der angiver, hvornår denne opdatering blev oprettet.
  • me: Oplysninger om den afspiller, der modtager denne opdatering.
  • andre: En række oplysninger om andre spillere, der deltager i det samme spil.
  • kugler: en række oplysninger om projektiler i spillet.
  • leaderboard: Aktuelle leaderboard-data. I dette indlæg vil vi ikke overveje dem.

7.1 Naiv klienttilstand

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

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Pænt og overskueligt! Men hvis det bare var så enkelt. En af grundene til, at denne implementering er problematisk: det begrænser rendering frame rate til serverens clock rate.

Billedhastighed: antal billeder (dvs. opkald render()) i sekundet eller FPS. Spil stræber normalt efter at opnå mindst 60 FPS.

Sæt kryds ved Rate: Den frekvens, hvormed serveren sender spilopdateringer til klienter. Det er ofte lavere end billedhastigheden. I vores spil kører serveren med en frekvens på 30 cyklusser i sekundet.

Hvis vi bare gengiver den seneste opdatering af spillet, så vil FPS i det væsentlige aldrig gå over 30, fordi vi får aldrig mere end 30 opdateringer i sekundet fra serveren. Også selvom vi ringer render() 60 gange i sekundet, så vil halvdelen af ​​disse opkald bare gentegne det samme og i det væsentlige gøre ingenting. Et andet problem med den naive implementering er, at den tilbøjelige til forsinkelser. Med en ideel internethastighed vil klienten modtage en spilopdatering præcis hver 33 ms (30 pr. sekund):

Oprettelse af et multiplayer .io-webspil
Desværre er intet perfekt. Et mere realistisk billede ville være:
Oprettelse af et multiplayer .io-webspil
Den naive implementering er praktisk talt det værste tilfælde, når det kommer til latency. Hvis en spilopdatering modtages med en forsinkelse på 50ms, så kundeboder 50 ms ekstra, fordi det stadig gengiver spiltilstanden fra den forrige opdatering. Du kan forestille dig, hvor ubehageligt dette er for spilleren: vilkårlig bremsning vil få spillet til at føles rykket og ustabilt.

7.2 Forbedret klienttilstand

Vi vil lave nogle forbedringer af den naive implementering. Først bruger vi gengivelsesforsinkelse i 100 ms. Det betyder, at klientens "aktuelle" tilstand altid vil halte 100 ms efter spillets tilstand på serveren. For eksempel hvis tiden på serveren er 150, så vil klienten gengive den tilstand, som serveren var i på det tidspunkt 50:

Oprettelse af et multiplayer .io-webspil
Dette giver os en 100ms buffer til at overleve uforudsigelige spilopdateringstider:

Oprettelse af et multiplayer .io-webspil
Udbetalingen for dette vil være permanent input lag i 100 ms. Dette er et mindre offer for glat gameplay - de fleste spillere (især afslappede spillere) vil ikke engang bemærke denne forsinkelse. Det er meget nemmere for folk at tilpasse sig en konstant 100ms latency end det er at spille med en uforudsigelig latency.

Vi kan også bruge en anden teknik kaldet forudsigelse på klientsiden, som gør et godt stykke arbejde med at reducere opfattet latens, men vil ikke blive dækket i dette indlæg.

En anden forbedring, vi bruger, er lineær interpolation. På grund af renderingsforsinkelse er vi normalt mindst én opdatering foran det aktuelle tidspunkt i klienten. Når man kalder getCurrentState(), vi kan udføre lineær interpolation mellem spilopdateringer lige før og efter det aktuelle tidspunkt i klienten:

Oprettelse af et multiplayer .io-webspil
Dette løser billedhastighedsproblemet: vi kan nu gengive unikke frames til enhver billedhastighed, vi ønsker!

7.3 Implementering af forbedret klienttilstand

Implementeringseksempel i src/client/state.js bruger både render lag og lineær interpolation, men ikke længe. 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);

  // 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 skridt er at finde ud af hvad currentServerTime(). Som vi så tidligere, inkluderer hver spilopdatering et servertidsstempel. Vi ønsker at bruge render latency til at rendere billedet 100ms bag serveren, men vi vil aldrig vide det aktuelle tidspunkt på serveren, fordi vi ikke kan vide, hvor lang tid det tog for nogen af ​​opdateringerne at komme til os. Internettet er uforudsigeligt, og dets hastighed kan variere meget!

For at omgå dette problem kan vi bruge en rimelig tilnærmelse: vi lad som om den første opdatering ankom med det samme. Hvis dette var sandt, så ville vi kende servertidspunktet på dette særlige tidspunkt! Vi gemmer serverens tidsstempel i firstServerTimestamp og behold vores lokal (klient) tidsstempel i samme øjeblik i gameStart.

Oh vent. Burde det ikke være servertid = klienttid? Hvorfor skelner vi mellem "servertidsstempel" og "klienttidsstempel"? Dette er et godt spørgsmål! Det viser sig, at de ikke er det samme. Date.now() vil returnere forskellige tidsstempler i klienten og serveren, og det afhænger af faktorer, der er lokale for disse maskiner. Antag aldrig, at tidsstempler vil være de samme på alle maskiner.

Nu forstår vi, hvad der gør currentServerTime(): den vender tilbage servertidsstemplet for den aktuelle gengivelsestid. Med andre ord, dette er serverens aktuelle tid (firstServerTimestamp <+ (Date.now() - gameStart)) minus gengivelsesforsinkelse (RENDER_DELAY).

Lad os nu tage et kig på, hvordan vi håndterer spilopdateringer. Når den modtages fra opdateringsserveren, kaldes den processGameUpdate()og vi gemmer den nye opdatering i et array gameUpdates. Derefter, for at kontrollere hukommelsesforbruget, fjerner vi alle de gamle opdateringer før basisopdateringfordi vi ikke har brug for dem længere.

Hvad er en "grundlæggende opdatering"? Det her den første opdatering finder vi ved at bevæge os baglæns fra serverens aktuelle tid. Kan du huske dette diagram?

Oprettelse af et multiplayer .io-webspil
Spilopdateringen direkte til venstre for "Client Render Time" er basisopdateringen.

Hvad bruges basisopdateringen til? Hvorfor kan vi droppe opdateringer til baseline? Lad os finde ud af det endelig overveje 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 sager:

  1. base < 0 betyder, at der ikke er nogen opdateringer før det aktuelle gengivelsestidspunkt (se implementeringen ovenfor getBaseUpdate()). Dette kan ske lige i starten af ​​spillet på grund af renderingsforsinkelse. I dette tilfælde bruger vi den seneste modtagne opdatering.
  2. base er den seneste opdatering vi har. Dette kan skyldes netværksforsinkelse 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 gengivelsestid, så vi kan interpolere!

Alt det der er tilbage state.js er en implementering af lineær interpolation, der er simpel (men kedelig) matematik. Hvis du selv vil udforske det, så åbn state.jsGithub.

Del 2. Backend-server

I denne del tager vi et kig på Node.js-backend, der styrer vores .io spil eksempel.

1. Serverindgangspunkt

For at administrere webserveren vil vi bruge en populær webramme for Node.js kaldet Express. Det vil blive konfigureret af vores server-indgangspunkt-fil 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}`);

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

  • Brug webpack-dev-middleware for automatisk at genopbygge vores udviklingspakker, eller
  • statisk overføre mappe dist/, hvor Webpack vil skrive vores filer efter produktionsbuilden.

Endnu en vigtig opgave server.js er at sætte serveren op socket.iosom bare forbinder 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);
});

Efter at have etableret en socket.io-forbindelse til serveren, satte vi hændelseshandlere op til den nye socket. Hændelseshandlere håndterer meddelelser modtaget fra klienter ved at uddelegere til et enkelt 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 laver et .io-spil, så vi mangler kun én kopi Game ("Spil") - alle spillere spiller i samme arena! I næste afsnit vil vi se, hvordan denne klasse fungerer. Game.

2. Spilservere

Klasse Game indeholder den vigtigste logik på serversiden. Den har to hovedopgaver: spillerstyring и spil simulering.

Lad os starte med den første opgave, spillerstyring.

game.js del 1

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

class Game {
  constructor() {
    this.sockets = {};
    this.players = {};
    this.bullets = [];
    this.lastUpdateTime = Date.now();
    this.shouldSendUpdate = false;
    setInterval(this.update.bind(this), 1000 / 60);
  }

  addPlayer(socket, username) {
    this.sockets[socket.id] = socket;

    // Generate a position to start this player at.
    const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    this.players[socket.id] = new Player(socket.id, username, x, y);
  }

  removePlayer(socket) {
    delete this.sockets[socket.id];
    delete this.players[socket.id];
  }

  handleInput(socket, dir) {
    if (this.players[socket.id]) {
      this.players[socket.id].setDirection(dir);
    }
  }

  // ...
}

I dette spil vil vi identificere spillerne ved feltet id deres socket.io socket (hvis du bliver forvirret, så gå tilbage til server.js). Socket.io selv tildeler hver socket en unik idså det behøver vi ikke bekymre os om. Jeg vil ringe til ham Spiller ID.

Med det i tankerne, lad os udforske instansvariabler i en klasse Game:

  • sockets er et objekt, der binder spiller-id'et til den socket, der er tilknyttet afspilleren. Det giver os mulighed for at få adgang til stikkontakter med deres spiller-id'er på konstant tid.
  • players er et objekt, der binder spiller-id'et til koden>Player-objektet

bullets er en række objekter Bullet, som ikke har nogen bestemt rækkefølge.
lastUpdateTime er tidsstemplet for sidste gang spillet blev opdateret. Vi får snart at se, hvordan det bliver brugt.
shouldSendUpdate er en hjælpevariabel. Vi vil også se dets brug snart.
metoder addPlayer(), removePlayer() и handleInput() ingen grund til at forklare, de bruges i server.js. Hvis du har brug for at genopfriske din hukommelse, skal du gå lidt højere tilbage.

Sidste linje constructor() starter op opdateringscyklus spil (med en frekvens på 60 opdateringer/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;
    }
  }

  // ...
}

fremgangsmåde update() indeholder måske den vigtigste logik på serversiden. Her er, hvad den gør, i rækkefølge:

  1. Beregner hvor længe dt gået siden sidst update().
  2. Opfrisker hvert projektil og ødelægger dem om nødvendigt. Vi vil se implementeringen af ​​denne funktionalitet senere. For nu er det nok for os at vide det bullet.update() vender tilbage truehvis projektilet skulle ødelægges (han trådte ud af arenaen).
  3. Opdaterer hver spiller og afføder et projektil, hvis det er nødvendigt. Vi vil også se denne implementering senere - player.update() kan returnere et objekt Bullet.
  4. Tjek for kollisioner mellem projektiler og spillere med applyCollisions(), som returnerer en række projektiler, der rammer spillere. For hvert projektil, der returneres, øger vi pointene for den spiller, der affyrede det (ved hjælp af player.onDealtDamage()) og fjern derefter projektilet fra arrayet bullets.
  5. Meddeler og ødelægger alle dræbte spillere.
  6. Sender en spilopdatering til alle spillere hvert sekund gange, når der ringes update(). Dette hjælper os med at holde styr på hjælpevariablen nævnt ovenfor. shouldSendUpdate. Fordi update() kaldet 60 gange/s, vi sender spilopdateringer 30 gange/s. Dermed, ur frekvens server clock er 30 ure/s (vi talte om clock rates i den første del).

Hvorfor kun sende spilopdateringer gennem tiden ? For at gemme kanal. 30 spilopdateringer i sekundet er meget!

Hvorfor ikke bare ringe update() 30 gange i sekundet? For at forbedre spilsimuleringen. De oftere kaldet update(), jo mere præcis bliver spilsimulationen. Men lad dig ikke rive med af antallet af udfordringer. update(), fordi dette er en beregningsmæssigt dyr opgave - 60 i sekundet er nok.

Resten af ​​klassen Game består af hjælpermetoder, der anvendes 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() ret simpelt - det sorterer spillerne efter score, tager top fem og returnerer brugernavn og score for hver.

createUpdate() brugt i update() at lave spilopdateringer, der distribueres til spillere. Dens hovedopgave er at kalde metoder serializeForUpdate()implementeret for klasser Player и Bullet. Bemærk, at den kun sender data til hver spiller om nærmeste spillere og projektiler - der er ingen grund til at overføre information 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 drage fordel af denne lighed mellem spillere og projektiler, lad os starte med at implementere basisklassen 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,
    };
  }
}

Der foregår ikke noget kompliceret her. Denne klasse vil være et godt ankerpunkt for forlængelsen. 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 < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

implementering Bullet meget kort! Vi har tilføjet til Object kun følgende udvidelser:

  • Brug af en pakke kortid til tilfældig generering id projektil.
  • Tilføjelse af et felt parentIDså du kan spore den spiller, der har skabt dette projektil.
  • Tilføjelse af en returværdi til update(), som er lig med truehvis projektilet er uden for arenaen (kan du huske, at vi talte om dette i sidste 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å et par flere felter bør opbevares i denne klasse. Hans metode update() gør meget arbejde, især returnerer det nyoprettede projektil, hvis der ikke er nogen tilbage fireCooldown (kan du huske, at vi talte om dette i forrige afsnit?). Det udvider også metoden serializeForUpdate(), fordi vi skal inkludere yderligere felter for spilleren i spilopdateringen.

At have en basisklasse Object - et vigtigt skridt for at undgå gentagelse af kode. For eksempel ingen klasse Object hvert spilobjekt skal have den samme implementering distanceTo(), og at kopiere alle disse implementeringer på tværs af flere filer ville være et mareridt. Dette bliver især vigtigt for store projekter.når antallet af ekspanderende Object klasser vokser.

4. Kollisionsdetektion

Det eneste, der er tilbage for os, er at genkende, hvornår projektilerne rammer spillerne! Husk dette stykke kode fra metoden update() i klassen Game:

game.js

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

class Game {
  // ...

  update() {
    // ...

    // Apply collisions, give players score for hitting bullets
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // ...
  }
}

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

  • Alle kolliderende objekter er cirkler, hvilket er den enkleste form til at implementere kollisionsdetektion.
  • Vi har allerede en metode distanceTo(), som vi implementerede i det foregående afsnit i klassen Object.

Sådan ser vores implementering af kollisionsdetektion ud:

kollisioner.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 simple kollisionsdetektion er baseret på det faktum, at to cirkler støder sammen, hvis afstanden mellem deres centre er mindre end summen af ​​deres radier. Her er tilfældet, hvor afstanden mellem to cirklers centre er nøjagtigt lig med summen af ​​deres radier:

Oprettelse af et multiplayer .io-webspil
Der er et par aspekter mere at overveje her:

  • Projektilet må ikke ramme den spiller, der har skabt det. Dette kan opnås ved at sammenligne bullet.parentID с player.id.
  • Projektilet må kun ramme én gang i det begrænsende tilfælde, hvor flere spillere kolliderer på samme tid. Vi løser dette problem ved hjælp af operatøren break: Så snart spilleren, der kolliderer med projektilet, er fundet, stopper vi søgningen og går videre til det næste projektil.

ende

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

Al eksempelkode er open source og lagt ud på Github.

Kilde: www.habr.com

Tilføj en kommentar