Skapa ett webbspel för flera spelare i .io-genren

Skapa ett webbspel för flera spelare i .io-genren
Släppt 2015 Agar.io blev stamfader till en ny genre games.io, vars popularitet har vuxit kraftigt sedan dess. Jag har själv upplevt ökningen i popularitet för .io-spel: under de senaste tre åren har jag skapat och sålt två spel i denna genre..

Om du aldrig har hört talas om dessa spel förut, är de gratis webbspel för flera spelare som är enkla att spela (inget konto krävs). De brukar placera många motståndare på en arena. Andra kända .io-spel: Slither.io и Diep.io.

I det här inlägget kommer vi att ta reda på hur skapa ett .io-spel från början. För att göra detta räcker bara kunskap om Javascript: du måste förstå saker som syntax ES6, nyckelord this и Löften. Även om du inte kan Javascript perfekt, kan du fortfarande förstå det mesta av inlägget.

Exempel på ett .io-spel

För utbildningshjälp hänvisar vi till exempel spel .io. Försök att spela det!

Skapa ett webbspel för flera spelare i .io-genren
Spelet är ganska enkelt: du styr ett skepp på en arena med andra spelare. Ditt skepp avfyrar automatiskt projektiler och du försöker träffa andra spelare samtidigt som du undviker deras projektiler.

1. Kort översikt/projektstruktur

rekommendera ladda ner källkod exempelspel så att du kan följa mig.

I exemplet används följande:

  • uttrycka är det mest populära webbramverket för Node.js som hanterar spelets webbserver.
  • socket.io — Websocket-bibliotek för utbyte av data mellan webbläsaren och servern.
  • Webpack - modulansvarig. Du kan läsa om varför du använder Webpack här.

Så här ser projektkatalogstrukturen ut:

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

offentlig/

Allt finns i mappen public/ kommer att sändas statiskt av servern. I public/assets/ innehåller bilder som används av vårt projekt.

src /

All källkod finns i mappen src/. Titlar client/ и server/ tala för sig själva och shared/ innehåller en konstantfil som importeras av både klienten och servern.

2. Sammansättningar/projektparametrar

Som nämnts ovan använder vi en modulansvarig för att bygga projektet Webpack. Lå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 följande:

  • src/client/index.js är ingångspunkten för Javascript (JS) klienten. Webpack kommer att börja härifrån och leta rekursivt efter andra importerade filer.
  • Utdata-JS från vårt Webpack-bygge kommer att finnas i katalogen dist/. Jag kallar den här filen vår JS-paket.
  • Vi använder Babel, och i synnerhet konfigurationen @babel/preset-env för att transpilera vår JS-kod för äldre webbläsare.
  • Vi använder en plugin för att extrahera all CSS som JS-filer refererar till och kombinera dem på ett ställe. Jag kommer att kalla den vår CSS-paket.

Du kanske har märkt konstiga paketfilnamn '[name].[contenthash].ext'. De innehåller filnamnsersättning Webpack: [name] kommer att ersättas med namnet på ingångspunkten (i vårt fall är det game) och [contenthash] kommer att ersättas med en hash av filinnehållet. Vi gör detta för att optimera projektet för hash - vi kan säga åt webbläsare att cachelagra våra JS-paket på obestämd tid pga om ett paket ändras ändras dess filnamn också (ändringar contenthash). Det färdiga resultatet blir vyns filnamn game.dbeee76e91a97d0c7207.js.

fil webpack.common.js är baskonfigurationsfilen som vi importerar till utvecklings- och färdiga projektkonfigurationer. 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 distribution till produktion.

Lokal inställning

Jag rekommenderar att du installerar projektet på din lokala dator så att du kan följa stegen som anges i det här inlägget. Installationen är enkel: först måste systemet ha Nod и NPM. Nästa måste du göra

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

och du är redo att gå! Kör bara för att starta utvecklingsservern

$ npm run develop

och gå till din webbläsare lokalvärd: 3000. Utvecklingsservern kommer automatiskt att bygga om JS- och CSS-paketen när kodändringar inträffar - bara uppdatera sidan för att se alla ändringar!

3. Kundentréer

Låt oss komma ner till själva spelkoden. Först behöver vi en sida index.html, när du besöker webbplatsen kommer webbläsaren att ladda den först. Vår sida kommer att vara ganska enkel:

index.html

Ett exempel på .io-spel  SPELA

Detta kodexempel har förenklats något för tydlighetens skull, och jag kommer att göra detsamma med många av de andra exemplen i inlägget. Du kan alltid titta på hela koden på Github.

Vi har:

  • HTML5 Canvas-element (<canvas>), som 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 "PLAY" (<button>).

När hemsidan har laddats kommer webbläsaren att börja köra Javascript-kod, med startpunkten 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 här kan låta komplicerat, men det händer faktiskt inte mycket här:

  1. Importera flera andra JS-filer.
  2. Importera CSS (så att Webpack vet att de ska inkluderas i vårt CSS-paket).
  3. Запуск connect() för att upprätta en anslutning till servern och starta downloadAssets() för att ladda ner de bilder som behövs för att rendera spelet.
  4. Efter att ha slutfört steg 3 huvudmenyn visas (playMenu).
  5. Ställa in klickhanteraren för "PLAY"-knappen. När knappen trycks in startar koden spelet och talar om för servern att vi är redo att spela.

Det huvudsakliga "köttet" i vår klient-serverlogik finns i de filer som importerades av filen index.js. Nu ska vi titta på dem alla i ordning.

4. Utbyte av kunddata

I det här spelet 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 и servern kan skicka meddelanden till oss över samma anslutning.

Vi kommer att ha en fil src/client/networking.jssom ska ta hand om alla kommunikation med servern:

nätverk.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);
};

Denna kod är också något förkortad för tydlighetens skull.

Det är tre huvudsakliga saker som händer i den här filen:

  • Vi försöker ansluta till servern. connectedPromise endast tillåtet när vi har upprättat en anslutning.
  • Om anslutningen lyckas registrerar vi återuppringningsfunktioner (processGameUpdate() и onGameOver()) för meddelanden som vi kan ta emot från servern.
  • Vi exporterar play() и updateDirection()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 detta måste vi ladda ner alla bilder (resurser) som behövs för detta. Låt oss skriva en resurshanterare:

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

Resurshantering är inte så svårt att genomföra! Huvudpoängen är att lagra ett föremål assets, som binder filnamnsnyckeln till objektvärdet Image. När resursen är laddad sparar vi den i ett objekt assets för ett snabbt mottagande i framtiden. När kommer nedladdning av varje enskild resurs tillåtas (det vill säga nedladdning alla resurser), tillåter vi downloadPromise.

När du har laddat ner resurserna kan du börja rendera. Som sagt tidigare, för att rita på en webbsida vi använder HTML5 Canvas (<canvas>). Vårt spel är ganska enkelt, så vi behöver bara rendera följande:

  1. Фон
  2. Spelarskepp
  3. Andra spelare i spelet
  4. ammunition

Här är de viktiga utdragen src/client/render.js, som drar exakt de fyra punkter som anges 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;

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

Denna kod är också förkortad för tydlighetens skull.

render() är huvudfunktionen för denna fil. startRendering() и stopRendering() styra aktiveringen av renderingscykeln vid 60 FPS.

Specifika implementeringar av individuella renderingshjälpfunktioner (till exempel renderBullet()) är 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 tidigare setts i asset.js!

Om du är intresserad av att utforska andra renderingshjälpfunktioner, läs resten av src/client/render.js.

6. Kundinmatning

Det är dags att göra ett spel spelbar! Kontrollschemat kommer att vara mycket enkelt: för att ändra rörelseriktningen kan du använda musen (på en dator) eller peka på skärmen (på en mobil enhet). För att genomföra detta kommer vi att registrera oss Evenemangslyssnare för Mouse and Touch-händelser.
Kommer att ta hand om allt detta 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() är händelselyssnare som ringer updateDirection() (från networking.js) när en ingångshändelse inträffar (till exempel när musen flyttas). updateDirection() hanterar utbyte av meddelanden med servern, som bearbetar ingångshändelsen och uppdaterar speltillståndet därefter.

7. Kundstatus

Det här avsnittet är det svåraste i den första delen av inlägget. Bli inte avskräckt om du inte förstår det första gången du läser det! Du kan till och med hoppa över det och återkomma till det senare.

Den sista pusselbiten som behövs för att slutföra klient-serverkoden är tillstånd. Kommer du ihåg kodavsnittet från avsnittet Client Rendering?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() ska kunna ge oss det aktuella spelläget i klienten närsomhelst baserat på uppdateringar som tagits 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: Servertidsstämpel som anger när denna uppdatering skapades.
  • me: Information om spelaren som tar emot denna uppdatering.
  • andra: En mängd information om andra spelare som deltar i samma spel.
  • kulor: mängd information om projektiler i spelet.
  • leader: Aktuella resultattavla. Vi kommer inte att ta hänsyn till dem i detta inlägg.

7.1 Kundens naiva tillstånd

Naiv implementering getCurrentState() kan endast returnera data direkt från den senast mottagna speluppdateringen.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Vackert och tydligt! Men om det bara vore så enkelt. En av anledningarna till att denna implementering är problematisk: det begränsar renderingsbildhastigheten till serverns klockhastighet.

Bildhastighet: antal ramar (dvs samtal render()) per sekund, eller FPS. Spel strävar vanligtvis efter att uppnå minst 60 FPS.

Kryssa i Betygsätt: Frekvensen med vilken servern skickar speluppdateringar till klienter. Det är ofta lägre än bildfrekvensen. I vårt spel kör servern med 30 ticks per sekund.

Om vi ​​bara renderar den senaste speluppdateringen kommer FPS i princip aldrig att kunna överstiga 30 eftersom vi får aldrig mer än 30 uppdateringar per sekund från servern. Även om vi ringer render() 60 gånger per sekund, då kommer hälften av dessa samtal helt enkelt att rita om samma sak, utan att göra någonting. Ett annat problem med en naiv implementering är att den förseningar. Med idealisk internethastighet kommer klienten att få en speluppdatering exakt var 33:e ms (30 per sekund):

Skapa ett webbspel för flera spelare i .io-genren
Tyvärr är ingenting perfekt. En mer realistisk bild skulle vara:
Skapa ett webbspel för flera spelare i .io-genren
En naiv implementering är i stort sett det värsta fallet när det kommer till latens. Om en speluppdatering tas emot med en 50ms fördröjning, då klienten saktas ner med ytterligare 50 ms eftersom det fortfarande återger speltillståndet från den tidigare uppdateringen. Du kan föreställa dig hur obekvämt detta är för spelaren: på grund av godtyckliga nedgångar kommer spelet att verka ryckigt och instabilt.

7.2 Förbättrat kundtillstånd

Vi kommer att göra några förbättringar av den naiva implementeringen. För det första använder vi renderingsfördröjning med 100 ms. Detta innebär att klientens "aktuella" tillstånd alltid kommer att ligga 100 ms efter speltillståndet på servern. Till exempel om servertiden är 150, då återger klienten det tillstånd som servern var i vid den tidpunkten 50:

Skapa ett webbspel för flera spelare i .io-genren
Detta ger oss en 100ms buffert för att överleva den oförutsägbara timingen av speluppdateringar:

Skapa ett webbspel för flera spelare i .io-genren
Priset för detta kommer att vara permanent ingångsfördröjning med 100 ms. Detta är en mindre uppoffring för smidigt spelande - de flesta spelare (särskilt avslappnade) kommer inte ens att märka denna fördröjning. Det är mycket lättare för människor att anpassa sig till en konstant 100ms latens än att spela med oförutsägbar latens.

Vi kan använda en annan teknik som kallas "prognos på klientsidan", som gör ett bra jobb med att minska upplevd latens, men kommer inte att diskuteras i det här inlägget.

En annan förbättring vi använder är linjär interpolation. På grund av renderingsfördröjning ligger vi vanligtvis minst en uppdatering före den aktuella tiden i klienten. När du ringer getCurrentState(), kan vi uppfylla linjär interpolation mellan speluppdateringar precis före och efter den aktuella tiden i klienten:

Skapa ett webbspel för flera spelare i .io-genren
Detta löser bildhastighetsproblemet: vi kan nu rendera unika bildrutor med vilken bildhastighet vi behöver!

7.3 Implementera en förbättrad kundstatus

Exempelimplementering i src/client/state.js använder både renderingsfördröjning och linjär interpolation, men detta varar inte länge. 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);

  // 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örsta du behöver göra är att ta reda på vad den gör currentServerTime(). Som vi såg tidigare innehåller varje speluppdatering en servertidsstämpel. Vi vill använda renderingslatens för att rendera bilden 100 ms bakom servern, men vi kommer aldrig att veta den aktuella tiden på servern, eftersom vi inte kan veta hur lång tid det tog för någon av uppdateringarna att nå oss. Internet är oförutsägbart och dess hastighet kan variera mycket!

För att komma runt detta problem kan vi använda en rimlig uppskattning: vi låt oss låtsas att den första uppdateringen kom direkt. Om detta var sant, då skulle vi veta serverns tid i just det ögonblicket! Vi lagrar serverns tidsstämpel i firstServerTimestamp och rädda vår lokal (klient) tidsstämpel i samma ögonblick in gameStart.

Åh, vänta lite. Borde det inte finnas tid på servern = tid på klienten? Varför gör vi skillnad på "servertidsstämpel" och "klienttidsstämpel"? Det här är en bra fråga! Det visar sig att dessa inte är samma sak. Date.now() kommer att returnera olika tidsstämplar i klienten och servern och detta beror på faktorer som är lokala för dessa maskiner. Anta aldrig att tidsstämplarna kommer att vara samma på alla maskiner.

Nu förstår vi vad det gör currentServerTime(): den återkommer servertidsstämpel för den aktuella renderingstiden. Med andra ord, detta är den aktuella servertiden (firstServerTimestamp <+ (Date.now() - gameStart)) minus renderingsfördröjning (RENDER_DELAY).

Låt oss nu titta på hur vi hanterar speluppdateringar. När en uppdatering tas emot från servern anropas den processGameUpdate(), och vi sparar den nya uppdateringen i en array gameUpdates. Sedan, för att kontrollera minnesanvändningen, tar vi bort alla gamla uppdateringar till basuppdateringför vi behöver dem inte längre.

Vad är en "kärnuppdatering"? Detta den första uppdateringen hittar vi genom att flytta bakåt från den aktuella servertiden. Kommer du ihåg detta diagram?

Skapa ett webbspel för flera spelare i .io-genren
Speluppdateringen direkt till vänster om "Client Render Time" är basuppdateringen.

Vad används basuppdateringen till? Varför kan vi släppa uppdateringar till basen? För att förstå detta, låt oss till sist låt oss titta på genomförandet 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 ärenden:

  1. base < 0 betyder att det inte finns några uppdateringar förrän den aktuella renderingstiden (se implementeringen ovan getBaseUpdate()). Detta kan hända direkt i början av spelet på grund av renderingsfördröjning. I det här fallet använder vi den senaste mottagna uppdateringen.
  2. base är den senaste uppdateringen vi har. Detta kan hända på grund av nätverkslatens eller dålig internetanslutning. Även i detta fall använder vi den senaste uppdateringen vi har.
  3. Vi har en uppdatering både före och efter aktuell renderingstid, så vi kan interpolera!

Allt som är kvar state.js är en implementering av linjär interpolation som är enkel (men tråkig) matematik. Om du vill utforska det själv, öppna state.jsGithub.

Del 2. Backend-server

I den här delen kommer vi att titta på Node.js backend som styr vår exempel på ett .io-spel.

1. Serveringångspunkt

För att hantera webbservern kommer vi att använda ett populärt webbramverk för Node.js som kallas uttrycka. Det kommer att konfigureras av vår serveringångsfil 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}`);

Kommer du ihåg att vi i den första delen diskuterade Webpack? Det är här vi kommer att använda våra Webpack-konfigurationer. Vi kommer att tillämpa dem på två sätt:

  • Använd webpack-dev-mellanprogram för att automatiskt bygga om våra utvecklingspaket, eller
  • Överför en mapp statiskt dist/, där Webpack kommer att skriva våra filer efter produktionsbygget.

En annan viktig uppgift server.js består av att sätta upp servern socket.iosom helt enkelt ansluter till Express-servern:

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 att ha upprättat en socket.io-anslutning med servern konfigurerar vi händelsehanterare för den nya socket. Händelsehanterare behandlar meddelanden som tas emot från klienter genom att delegera till ett enskilt 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 skapar ett .io-spel, så vi behöver bara en kopia Game ("Spel") – alla spelare spelar på samma arena! I nästa avsnitt ska vi se hur den här klassen fungerar Game.

2. Spelservrar

Klass Game innehåller den viktigaste logiken på serversidan. Den har två huvuduppgifter: spelarhantering и spelsimulering.

Låt oss börja med den första uppgiften - att hantera spelarna.

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 det här spelet kommer vi att identifiera spelare efter fält id deras socket socket.io (om du är förvirrad, gå tillbaka till server.js). Socket.io själv tilldelar varje socket en unik id, så vi behöver inte oroa oss för det. jag ska ringa honom Spelar-ID.

Med det i åtanke, låt oss undersöka instansvariablerna i klassen Game:

  • sockets är ett objekt som binder spelarens ID till socket som är associerat med spelaren. Det tillåter oss att komma åt uttag genom deras spelar-ID över tiden.
  • players är ett objekt som binder spelarens ID till koden>Spelarobjekt

bullets är en samling objekt Bullet, inte har en specifik ordning.
lastUpdateTime - Det här är tidsstämpeln för den senaste speluppdateringen. Vi får se hur det används snart.
shouldSendUpdate är en hjälpvariabel. Vi kommer också att se dess användning snart.
metoder addPlayer(), removePlayer() и handleInput() du behöver inte förklara, de används i server.js. Om du behöver en uppfräschning, gå tillbaka lite högre.

Sista raden constructor() kuttrar uppdateringscykel spel (med en frekvens på 60 uppdateringar/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;
    }
  }

  // ...
}

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

  1. Räknar ut vad klockan är dt det har varit sedan sist update().
  2. Uppdaterar varje projektil och förstör dem vid behov. Vi kommer att se implementeringen av den här funktionen senare. För nu räcker det för oss att veta det bullet.update() returnerar true, om projektilen måste förstöras (han gick utanför arenan).
  3. Uppdaterar varje spelare och skapar en projektil vid behov. Vi kommer också att se denna implementering senare - player.update() kan returnera ett objekt Bullet.
  4. Kontrollerar kollisioner mellan projektiler och spelare som använder applyCollisions(), som returnerar en rad projektiler som träffar spelare. För varje projektil som returneras ökar vi poängen för spelaren som avfyrade den (med player.onDealtDamage()), och ta sedan bort projektilen från arrayen bullets.
  5. Meddelar och förstör alla dödade spelare.
  6. Skickar en speluppdatering till alla spelare varje sekund gånger när man ringer update(). Hjälpvariabeln som nämns ovan hjälper oss att spåra detta shouldSendUpdate. Därför att update() ringt 60 gånger/s, vi skickar speluppdateringar 30 gånger/s. Således, klockfrekvens servern är 30 klockcykler/s (vi pratade om klockfrekvensen i den första delen).

Varför bara skicka speluppdateringar genom tiden ? För att spara kanal. 30 speluppdateringar per sekund är mycket!

Varför inte bara ringa då? update() 30 gånger per sekund? För att förbättra spelsimuleringen. Ju oftare det kallas update(), desto mer exakt blir spelsimuleringen. Men låt dig inte ryckas för mycket av antalet utmaningar update(), eftersom detta är en beräkningsmässigt dyr uppgift - 60 per sekund är ganska tillräckligt.

Resten av klassen Game består av hjälpmetoder 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) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() Det är ganska enkelt - det sorterar spelare efter poäng, tar de fem bästa och returnerar användarnamn och poäng för varje.

createUpdate() Använd i update() för att skapa speluppdateringar som distribueras till spelare. Dess huvudsakliga uppgift är att anropa metoder serializeForUpdate(), implementerad för klasser Player и Bullet. Observera att den bara överför data till varje spelare om närmast spelare och projektiler - det finns inget behov av att överföra information om spelobjekt som ligger långt från spelaren!

3. Spelobjekt på servern

I vårt spel är projektiler och spelare faktiskt väldigt lika: de är abstrakta, runda rörliga spelobjekt. För att dra fördel av denna likhet mellan spelare och projektiler, låt oss börja med att implementera en basklass Object:

object.js

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

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

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

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

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

Det är inget komplicerat som händer här. Denna klass kommer att vara en bra utgångspunkt för expansion. Låt oss se hur klassen Bullet användningsområden 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;
  }
}

genomförande Bullet mycket kort! Vi har lagt till Object endast följande tillägg:

  • Använder paketet kortid för slumpmässig generering id projektil.
  • Lägger till ett fält parentID, så att du kan spåra spelaren som skapade den här projektilen.
  • Lägga till returvärdet till update(), vilket är lika true, om projektilen är utanför arenan (minns du att vi pratade om detta i förra avsnittet?).

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 bör lagra några fler fält. Hans metod update() gör mer arbete, i synnerhet att returnera den nyskapade projektilen om det inte finns några kvar fireCooldown (kommer du ihåg att vi pratade om detta i föregående avsnitt?). Det utökar också metoden serializeForUpdate(), eftersom vi måste inkludera ytterligare fält för spelaren i speluppdateringen.

Tillgänglighet för en basklass Object - ett viktigt steg för att undvika kodupprepning. Till exempel utan klass Object varje spelobjekt måste ha samma implementering distanceTo(), och att kopiera och klistra in alla dessa implementeringar över flera filer skulle vara en mardröm. Detta blir särskilt viktigt för stora projekt, när antalet expanderande Object klasserna växer.

4. Kollisionsdetektering

Det enda som återstår för oss att göra är att känna igen när projektilerna träffar spelarna! Kom ihåg detta kodavsnitt från metoden update() på lektionen Game:

game.js

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

class Game {
  // ...

  update() {
    // ...

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

    // ...
  }
}

Vi måste implementera metoden applyCollisions(), som returnerar alla projektiler som träffar spelare. Lyckligtvis är detta inte så svårt att göra eftersom

  • Alla kolliderande föremål är cirklar, och detta är den enklaste formen för att implementera kollisionsdetektering.
  • Vi har redan en metod distanceTo(), som vi implementerade i klassen i föregående avsnitt Object.

Så här ser vår implementering av kollisionsdetektering 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 kollisionsdetektering är baserad på det faktum 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 två cirklars mittpunkter är exakt lika med summan av deras radier:

Skapa ett webbspel för flera spelare i .io-genren
Här måste du vara uppmärksam på ytterligare ett par aspekter:

  • Projektilen får inte träffa spelaren som skapade den. Detta kan uppnås genom att jämföra bullet.parentID с player.id.
  • Projektilen ska bara träffa en gång i det extrema fallet att träffa flera spelare samtidigt. Vi kommer att lösa detta problem med hjälp av operatören break: När en spelare som kolliderar med en projektil hittas slutar vi söka och går vidare till nästa projektil.

end

Det är allt! Vi har täckt allt du behöver veta för att skapa ett .io-webbspel. Vad kommer härnäst? Bygg ditt eget .io-spel!

All exempelkod är öppen källkod och publiceras på Github.

Källa: will.com

Lägg en kommentar