Kreante Plurludantan .io Retan Ludon

Kreante Plurludantan .io Retan Ludon
Liberigite en 2015 Agar.io iĝis la prapatro de nova ĝenro ludoj .iokiu kreskis en populareco ekde tiam. Mi persone spertis la plialtiĝon de populareco de .io-ludoj: dum la lastaj tri jaroj, mi faris kreis kaj vendis du ludojn de ĉi tiu ĝenro..

Se vi neniam antaŭe aŭdis pri ĉi tiuj ludoj, ĉi tiuj estas senpagaj plurludantaj interretaj ludoj, kiuj estas facile ludeblaj (ne necesas konto). Ili kutime alfrontas multajn kontraŭstarajn ludantojn en la sama areno. Aliaj famaj .io-ludoj: Slither.io и Diep.io.

En ĉi tiu afiŝo, ni esploros kiel krei ludon .io de nulo. Por tio, nur kono de Javascript sufiĉos: vi devas kompreni aferojn kiel sintakson ES6, ŝlosilvorto this и promesoj. Eĉ se via scio pri Javascript ne estas perfekta, vi ankoraŭ povas kompreni la plej grandan parton de la afiŝo.

.io ludo ekzemplo

Por lerni helpon, ni referencos .io ludo ekzemplo. Provu ludi ĝin!

Kreante Plurludantan .io Retan Ludon
La ludo estas sufiĉe simpla: vi regas ŝipon en areno kie estas aliaj ludantoj. Via ŝipo aŭtomate pafas ĵetaĵojn kaj vi provas trafi aliajn ludantojn evitante iliajn ĵetaĵojn.

1. Mallonga superrigardo / strukturo de la projekto

Mi rekomendas elŝutu fontkodon ekzempla ludo por ke vi povu sekvi min.

La ekzemplo uzas la jenon:

  • esprimi estas la plej populara interreta kadro Node.js, kiu administras la retservilon de la ludo.
  • ingo.io - Websocket-biblioteko por interŝanĝi datumojn inter retumilo kaj servilo.
  • Retpakaĵo - modula administranto. Vi povas legi pri kial uzi Webpack. tie.

Jen kiel aspektas la projekta dosierujo-strukturo:

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

publika/

Ĉio en dosierujo public/ estos statike sendita de la servilo. EN public/assets/ enhavas bildojn uzatajn de nia projekto.

src /

Ĉiu fontkodo estas en la dosierujo src/. Titoloj client/ и server/ parolas por si mem kaj shared/ enhavas konstante dosieron importitan de kaj la kliento kaj la servilo.

2. Asembleoj/projektaj agordoj

Kiel menciite supre, ni uzas la modulan administranton por konstrui la projekton. Retpakaĵo. Ni rigardu nian Webpack-agordon:

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

La plej gravaj linioj ĉi tie estas:

  • src/client/index.js estas la enirpunkto de la Javascript (JS) kliento. Webpack komencos de ĉi tie kaj serĉos rekursie aliajn importitajn dosierojn.
  • La eligo JS de nia Webpack-konstruaĵo troviĝos en la dosierujo dist/. Mi nomos ĉi tiun dosieron nia js-pakaĵo.
  • Ni uzas babel, kaj precipe la agordo @babel/preset-env al transpiro de nia JS-kodo por pli malnovaj retumiloj.
  • Ni uzas kromprogramon por ĉerpi ĉiujn CSS referencitajn de la JS-dosieroj kaj kombini ilin en unu loko. Mi nomos ĝin nia css-pakaĵo.

Vi eble rimarkis strangajn pakajn dosiernomojn '[name].[contenthash].ext'. Ili enhavas anstataŭigoj de dosiernomoj Retpako: [name] estos anstataŭigita per la nomo de la enigpunkto (en nia kazo, ĉi tio game), kaj [contenthash] estos anstataŭigita per hash de la enhavo de la dosiero. Ni faras ĝin al optimumigi la projekton por hashing - vi povas diri al retumiloj konservi niajn JS-pakaĵojn senfine, ĉar se pako ŝanĝiĝas, tiam ankaŭ ĝia dosiernomo ŝanĝiĝas (ŝanĝoj contenthash). La fina rezulto estos la nomo de la vidodosiero game.dbeee76e91a97d0c7207.js.

dosiero webpack.common.js estas la baza agorda dosiero, kiun ni importas en la evoluajn kaj finitajn projektajn agordojn. Jen ekzemplo de disvolva agordo:

webpack.dev.js

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

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

Por efikeco, ni uzas en la evoluprocezo webpack.dev.js, kaj ŝanĝas al webpack.prod.jspor optimumigi pakaĵgrandojn dum deplojado al produktado.

Loka agordo

Mi rekomendas instali la projekton sur loka maŝino por ke vi povu sekvi la paŝojn listigitajn en ĉi tiu afiŝo. La agordo estas simpla: unue, la sistemo devas esti instalita nodo и NPM. Poste vi devas fari

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

kaj vi estas preta iri! Por komenci la evoluservilon, simple rulu

$ npm run develop

kaj iru al TTT-legilo localhost: 3000. La evoluservilo aŭtomate rekonstruos la JS kaj CSS-pakaĵojn dum la kodo ŝanĝiĝas - nur refreŝigu la paĝon por vidi ĉiujn ŝanĝojn!

3. Klientaj Enirpunktoj

Ni iru al la ludkodo mem. Unue ni bezonas paĝon index.html, kiam vi vizitas la retejon, la retumilo ŝarĝos ĝin unue. Nia paĝo estos sufiĉe simpla:

index.html

Ekzemplo .io-ludo  LUDU

Ĉi tiu kodekzemplo estis iomete simpligita por klareco, kaj mi faros la samon kun multaj el la aliaj afiŝoekzemploj. La plena kodo ĉiam videblas ĉe GitHub.

Ni havas:

  • HTML5 kanvasa elemento (<canvas>) kiun ni uzos por redoni la ludon.
  • <link> aldoni nian CSS-pakaĵon.
  • <script> aldoni nian Javascript-pakaĵon.
  • Ĉefa menuo kun uzantnomo <input> kaj la butono LUDO (<button>).

Post ŝarĝo de la hejmpaĝo, la retumilo komencos ekzekuti Javascript-kodon, komencante de la enirpunkto JS-dosiero: src/client/index.js.

indekso.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);
  };
});

Ĉi tio povas soni komplika, sed ne multe okazas ĉi tie:

  1. Importado de pluraj aliaj JS-dosieroj.
  2. CSS-importo (do Webpack scias inkluzivi ilin en nia CSS-pakaĵo).
  3. Lanĉu connect() por establi konekton kun la servilo kaj kuri downloadAssets() por elŝuti bildojn necesajn por bildigi la ludon.
  4. Post kompletigo de la 3-a etapo la ĉefa menuo montriĝas (playMenu).
  5. Agordi la prizorganton por premado de la butono "PLAY". Kiam la butono estas premata, la kodo pravigas la ludon kaj diras al la servilo, ke ni pretas ludi.

La ĉefa "viando" de nia kliento-servila logiko estas en tiuj dosieroj, kiuj estis importitaj de la dosiero index.js. Nun ni konsideros ilin ĉiujn en ordo.

4. Interŝanĝo de klientaj datumoj

En ĉi tiu ludo, ni uzas konatan bibliotekon por komuniki kun la servilo ingo.io. Socket.io havas denaskan subtenon Retejoj, kiuj bone taŭgas por dudirekta komunikado: ni povas sendi mesaĝojn al la servilo и la servilo povas sendi mesaĝojn al ni per la sama konekto.

Ni havos unu dosieron src/client/networking.jskiu prizorgos ĉiuj komunikado kun la servilo:

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('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);
};

Ĉi tiu kodo ankaŭ estis iomete mallongigita por klareco.

Estas tri ĉefaj agoj en ĉi tiu dosiero:

  • Ni provas konektiĝi al la servilo. connectedPromise nur permesite kiam ni establis konekton.
  • Se la konekto sukcesas, ni registras revokfunkciojn (processGameUpdate() и onGameOver()) por mesaĝoj, kiujn ni povas ricevi de la servilo.
  • Ni eksportas play() и updateDirection()por ke aliaj dosieroj povu uzi ilin.

5. Klienta Reprezentado

Estas tempo montri la bildon sur la ekrano!

…sed antaŭ ol ni povas fari tion, ni devas elŝuti ĉiujn bildojn (rimedojn) kiuj estas necesaj por tio. Ni skribu rimedan administranton:

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

Administrado de rimedoj ne estas tiom malfacile efektivigi! La ĉefa ideo estas stoki objekton assets, kiu ligos la ŝlosilon de la dosiernomo al la valoro de la objekto Image. Kiam la rimedo estas ŝarĝita, ni konservas ĝin en objekto assets por rapida aliro en la estonteco. Kiam ĉiu individua rimedo estos permesita elŝuti (tio estas, ĉiuj rimedoj), ni permesas downloadPromise.

Post elŝuto de la rimedoj, vi povas komenci bildigon. Kiel dirite antaŭe, por desegni sur retpaĝo, ni uzas HTML5 Kanvaso (<canvas>). Nia ludo estas sufiĉe simpla, do ni nur bezonas desegni la jenajn:

  1. Fono
  2. Ludanta ŝipo
  3. Aliaj ludantoj en la ludo
  4. Konkoj

Jen la gravaj fragmentoj src/client/render.js, kiuj prezentas ĝuste la kvar erojn listigitajn supre:

redonu.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);
}

Ĉi tiu kodo ankaŭ estas mallongigita por klareco.

render() estas la ĉefa funkcio de ĉi tiu dosiero. startRendering() и stopRendering() kontroli la aktivigon de la bildiga buklo je 60 FPS.

Konkretaj efektivigoj de individuaj bildaj helpfunkcioj (ekz. renderBullet()) ne estas tiom gravaj, sed jen unu simpla ekzemplo:

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

Notu, ke ni uzas la metodon getAsset(), kiu antaŭe estis vidita en asset.js!

Se vi interesiĝas lerni pri aliaj bildighelpantoj, tiam legu la ceterajn. src/client/render.js.

6. Klienta enigo

Estas tempo fari ludon ludebla! La kontrolskemo estos tre simpla: por ŝanĝi la direkton de movado, vi povas uzi la muson (en komputilo) aŭ tuŝi la ekranon (en poŝtelefono). Por efektivigi ĉi tion, ni registriĝos Eventaj Aŭskultantoj por Muso kaj Tuŝo-eventoj.
Prizorgos ĉion ĉi src/client/input.js:

enigo.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() estas Eventaj Aŭskultantoj kiuj vokas updateDirection() (de networking.js) kiam okazas eniga evento (ekzemple kiam la muso estas movita). updateDirection() pritraktas mesaĝadon kun la servilo, kiu pritraktas la enigaĵokazaĵon kaj ĝisdatigas la ludstato laŭe.

7. Kliento Statuso

Ĉi tiu sekcio estas la plej malfacila en la unua parto de la afiŝo. Ne malkuraĝiĝu se vi ne komprenas ĝin la unuan fojon kiam vi legas ĝin! Vi povas eĉ preterlasi ĝin kaj reveni al ĝi poste.

La lasta peco de la enigmo bezonata por kompletigi la klientan/servilan kodon estas ŝtato. Ĉu vi memoras la kodan fragmenton de la sekcio Kliento Reprezentado?

redonu.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() devus povi doni al ni la nunan staton de la ludo en la kliento en ajna momento en la tempo surbaze de ĝisdatigoj ricevitaj de la servilo. Jen ekzemplo de ludĝisdatigo, kiun la servilo povas sendi:

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

Ĉiu ludĝisdatigo enhavas kvin identajn kampojn:

  • t: Servila tempomarko indikanta kiam ĉi tiu ĝisdatigo estis kreita.
  • me: Informoj pri la ludanto ricevanta ĉi tiun ĝisdatigon.
  • aliaj: aro da informoj pri aliaj ludantoj partoprenantaj en la sama ludo.
  • kugloj: aro da informoj pri ĵetaĵoj en la ludo.
  • leaderboard: Nunaj gvidtabulodatenoj. En ĉi tiu afiŝo, ni ne konsideros ilin.

7.1 Naiva klienta stato

Naiva efektivigo getCurrentState() povas nur rekte resendi la datumojn de la plej lastatempe ricevita ludĝisdatigo.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Bela kaj klara! Sed se nur tiel simple estus. Unu el la kialoj ĉi tiu efektivigo estas problema: ĝi limigas la bildigan framfrekvencon al la servila horloĝfrekvenco.

Framfrekvenco: nombro da kadroj (t.e. vokoj render()) je sekundo, aŭ FPS. Ludoj kutime strebas atingi almenaŭ 60 FPS.

Tik-Indico: La ofteco je kiu la servilo sendas ludĝisdatigojn al klientoj. Ĝi ofte estas pli malalta ol la framfrekvenco. En nia ludo, la servilo funkcias kun ofteco de 30 cikloj por sekundo.

Se ni nur prezentas la lastan ĝisdatigon de la ludo, tiam la FPS esence neniam superos 30, ĉar ni neniam ricevas pli ol 30 ĝisdatigojn sekundo de la servilo. Eĉ se ni vokas render() 60 fojojn je sekundo, tiam duono de ĉi tiuj vokoj simple redesenos la saman aferon, esence farante nenion. Alia problemo kun la naiva efektivigo estas ke ĝi inklina al prokrastoj. Kun ideala interreta rapideco, la kliento ricevos ludon ĝisdatigon precize ĉiujn 33ms (30 sekundo):

Kreante Plurludantan .io Retan Ludon
Bedaŭrinde nenio estas perfekta. Pli realisma bildo estus:
Kreante Plurludantan .io Retan Ludon
La naiva efektivigo estas preskaŭ la plej malbona kazo kiam temas pri latenteco. Se ludo ĝisdatigo estas ricevita kun prokrasto de 50ms, tiam klientbudoj kromajn 50ms ĉar ĝi ankoraŭ prezentas la ludstato de la antaŭa ĝisdatigo. Vi povas imagi kiom malkomforta ĉi tio estas por la ludanto: arbitra bremsado sentos la ludon saka kaj malstabila.

7.2 Plibonigita klienta stato

Ni faros kelkajn plibonigojn al la naiva efektivigo. Unue, ni uzas malfruo prokrasto dum 100 ms. Ĉi tio signifas, ke la "nuna" stato de la kliento ĉiam postrestas post la stato de la ludo sur la servilo je 100ms. Ekzemple, se la tempo sur la servilo estas 150, tiam la kliento redonos la staton en kiu la servilo estis tiutempe 50:

Kreante Plurludantan .io Retan Ludon
Ĉi tio donas al ni 100ms-bufron por postvivi neantaŭvideblajn ludajn ĝisdatigojn:

Kreante Plurludantan .io Retan Ludon
La rekompenco por ĉi tio estos konstanta enigo malfruo dum 100 ms. Ĉi tio estas negrava ofero por glata ludado - la plej multaj ludantoj (precipe hazardaj ludantoj) eĉ ne rimarkos ĉi tiun prokraston. Estas multe pli facile por homoj adaptiĝi al konstanta 100ms-latenteco ol ludi kun neantaŭvidebla latenco.

Ni ankaŭ povas uzi alian teknikon nomatan klientflanka prognozo, kiu faras bonan laboron por redukti la perceptitan latentecon, sed ne estos kovrita en ĉi tiu afiŝo.

Alia plibonigo, kiun ni uzas, estas lineara interpolado. Pro bildigo, ni kutime estas almenaŭ unu ĝisdatigo antaŭ la nuna tempo en la kliento. Kiam vokita getCurrentState(), ni povas ekzekuti lineara interpolado inter ludaj ĝisdatigoj ĵus antaŭ kaj post la nuna tempo en la kliento:

Kreante Plurludantan .io Retan Ludon
Ĉi tio solvas la problemon de framfrekvenco: ni nun povas fari unikajn kadrojn laŭ ajna framfrekvenco, kiun ni volas!

7.3 Efektivigo de plifortigita klienta stato

Ekzemplo de efektivigo en src/client/state.js uzas kaj bildigan malfruon kaj linearan interpoladon, sed ne longe. Ni rompu la kodon en du partojn. Jen la unua:

state.js parto 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;
}

La unua paŝo estas eltrovi kio currentServerTime(). Kiel ni vidis pli frue, ĉiu ludĝisdatigo inkluzivas servilan tempomarkon. Ni volas uzi bildigi latentecon por bildigi la bildon 100ms malantaŭ la servilo, sed ni neniam scios la nunan horon sur la servilo, ĉar ni ne povas scii kiom da tempo daŭris por iu el la ĝisdatigoj por atingi nin. Interreto estas neantaŭvidebla kaj ĝia rapideco povas multe varii!

Por ĉirkaŭiri ĉi tiun problemon, ni povas uzi akcepteblan proksimumon: ni ŝajnigu, ke la unua ĝisdatigo alvenis tuj. Se ĉi tio estus vera, tiam ni scius la servilan tempon en ĉi tiu aparta momento! Ni konservas la tempomarkon de la servilo firstServerTimestamp kaj konservu nian loka (kliento) tempomarko en la sama momento en gameStart.

Ho atendu. Ĉu ne devus esti servila tempo = klienta tempo? Kial ni distingas inter "servila tempomarko" kaj "klienta tempomarko"? Ĉi tio estas bonega demando! Montriĝas, ke ili ne estas la sama afero. Date.now() resendos malsamajn tempomarkojn en la kliento kaj servilo, kaj ĝi dependas de lokaj faktoroj al ĉi tiuj maŝinoj. Neniam supozu, ke tempomarkoj estos la samaj en ĉiuj maŝinoj.

Nun ni komprenas kion faras currentServerTime(): ĝi revenas la servila tempomarko de la nuna rendertempo. Alivorte, ĉi tiu estas la nuna horo de la servilo (firstServerTimestamp <+ (Date.now() - gameStart)) minus prokrasto de bildigo (RENDER_DELAY).

Nun ni rigardu kiel ni pritraktas ludajn ĝisdatigojn. Ricevite de la ĝisdatiga servilo, ĝi estas vokita processGameUpdate()kaj ni konservas la novan ĝisdatigon al tabelo gameUpdates. Poste, por kontroli la uzadon de la memoro, ni antaŭe forigas ĉiujn malnovajn ĝisdatigojn baza ĝisdatigoĉar ni ne plu bezonas ilin.

Kio estas "baza ĝisdatigo"? Ĉi tio la unuan ĝisdatigon, kiun ni trovas movante malantaŭen de la nuna tempo de la servilo. Ĉu vi memoras ĉi tiun diagramon?

Kreante Plurludantan .io Retan Ludon
La ludĝisdatigo rekte maldekstre de "Client Render Time" estas la baza ĝisdatigo.

Por kio estas uzata la baza ĝisdatigo? Kial ni povas faligi ĝisdatigojn al bazlinio? Por eltrovi ĉi tion, ni fine pripensu la efektivigon getCurrentState():

state.js parto 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),
    };
  }
}

Ni traktas tri kazojn:

  1. base < 0 signifas ke ekzistas neniuj ĝisdatigoj ĝis la nuna rendertempo (vidu supre efektivigon getBaseUpdate()). Ĉi tio povas okazi ĝuste ĉe la komenco de la ludo pro bildigo. En ĉi tiu kazo, ni uzas la lastan ĝisdatigon ricevitan.
  2. base estas la plej nova ĝisdatigo, kiun ni havas. Ĉi tio povas esti pro reto prokrasto aŭ malbona interreta konekto. En ĉi tiu kazo, ni ankaŭ uzas la lastan ĝisdatigon, kiun ni havas.
  3. Ni havas ĝisdatigon kaj antaŭ kaj post la nuna rendertempo, do ni povas interpoli!

Ĉio, kio restas ene state.js estas efektivigo de lineara interpolado kiu estas simpla (sed enuiga) matematiko. Se vi volas esplori ĝin mem, tiam malfermu state.js sur GitHub.

Parto 2. Backend servilo

En ĉi tiu parto, ni rigardos la backend de Node.js, kiu regas nian .io ludo ekzemplo.

1. Servila Enireja Punkto

Por administri la retservilon, ni uzos popularan retan kadron por Node.js nomita esprimi. Ĝi estos agordita de nia servila enirpunktodosiero src/server/server.js:

server.js parto 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}`);

Ĉu vi memoras, ke en la unua parto ni diskutis Webpack? Jen kie ni uzos niajn Webpack-agordojn. Ni uzos ilin en du manieroj:

  • Uzu webpack-dev-middleware por aŭtomate rekonstrui niajn evolupakaĵojn, aŭ
  • statike translokigi dosierujon dist/, en kiu Webpack skribos niajn dosierojn post la produktado-konstruo.

Alia grava tasko server.js estas agordi la servilon ingo.iokiu nur konektas al la Express-servilo:

server.js parto 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);
});

Sukcese establinte socket.io-konekton al la servilo, ni starigis eventajn traktilojn por la nova ingo. Okazaĵtraktantoj pritraktas mesaĝojn ricevitajn de klientoj per delegado al singleton objekto game:

server.js parto 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);
}

Ni kreas ludon .io, do ni bezonas nur unu kopion Game ("Ludo") - ĉiuj ludantoj ludas en la sama areno! En la sekva sekcio, ni vidos kiel ĉi tiu klaso funkcias. Game.

2. Ludaj serviloj

Класс Game enhavas la plej gravan logikon ĉe la servilo. Ĝi havas du ĉefajn taskojn: administrado de ludantoj и ludsimulado.

Ni komencu per la unua tasko, ludantadministrado.

game.js parto 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);
    }
  }

  // ...
}

En ĉi tiu ludo, ni identigos la ludantojn laŭ la kampo id ilia ingo socket.io (se vi konfuziĝas, tiam reiru al server.js). Socket.io mem atribuas al ĉiu ingo unikaĵo iddo ni ne bezonas zorgi pri tio. Mi vokos lin Ludanto ID.

Konsiderante tion, ni esploru ekzemplajn variablojn en klaso Game:

  • sockets estas objekto kiu ligas la ludanto-ID al la ingo kiu estas rilata al la ludanto. Ĝi permesas al ni aliri ingojn per iliaj ludantaj ID en konstanta tempo.
  • players estas objekto, kiu ligas la ludantidentigilon al la kodo>Ludanta objekto

bullets estas tabelo de objektoj Bullet, kiu ne havas difinitan ordon.
lastUpdateTime estas la tempomarko de la lasta fojo, kiam la ludo estis ĝisdatigita. Ni vidos kiel ĝi estas uzata baldaŭ.
shouldSendUpdate estas helpvariablo. Ni ankaŭ vidos ĝian uzon baldaŭ.
Metodoj addPlayer(), removePlayer() и handleInput() ne necesas klarigi, ili estas uzataj en server.js. Se vi bezonas refreŝigi vian memoron, reiru iom pli alten.

Lasta linio constructor() ekfunkciigas ĝisdatiga ciklo ludoj (kun ofteco de 60 ĝisdatigoj/s):

game.js parto 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;
    }
  }

  // ...
}

Metodo update() enhavas eble la plej gravan pecon de servilflanka logiko. Jen kion ĝi faras, en ordo:

  1. Kalkulas kiom longe dt pasis ekde la lasta update().
  2. Refreŝigas ĉiun kuglon kaj detruas ilin se necese. Ni vidos la efektivigon de ĉi tiu funkcio poste. Nuntempe, sufiĉas por ni scii tion bullet.update() revenas truese la kuglo devas esti detruita (li elpaŝis el la areno).
  3. Ĝisdatigas ĉiun ludanton kaj generas kuglon se necese. Ni ankaŭ vidos ĉi tiun efektivigon poste - player.update() povas resendi objekton Bullet.
  4. Kontrolas por kolizioj inter ĵetaĵoj kaj ludantoj kun applyCollisions(), kiu resendas aron da ĵetaĵoj kiuj trafas ludantojn. Por ĉiu ĵetaĵo resendita, ni pliigas la poentojn de la ludanto kiu pafis ĝin (uzante player.onDealtDamage()) kaj tiam forigu la kuglon el la tabelo bullets.
  5. Sciigas kaj detruas ĉiujn mortigitajn ludantojn.
  6. Sendas ludĝisdatigon al ĉiuj ludantoj ĉiun sekundon fojoj kiam vokita update(). Ĉi tio helpas nin konservi trakon de la helpvariablo menciita supre. shouldSendUpdate... Ĉar update() vokita 60 fojojn/s, ni sendas ludĝisdatigojn 30 fojojn/s. Tiel, horloĝa frekvenco servila horloĝo estas 30 horloĝoj/s (ni parolis pri horloĝfrekvencoj en la unua parto).

Kial sendi ludajn ĝisdatigojn nur tra la tempo ? Por konservi kanalon. 30 ludaj ĝisdatigoj sekundo estas multe!

Kial ne simple voki update() 30 fojojn je sekundo? Por plibonigi la ludsimuladon. La pli ofte vokita update(), des pli preciza estos la ludsimulado. Sed ne tro forportu la nombron da defioj. update(), ĉar ĉi tio estas komputile multekosta tasko - 60 sekundo sufiĉas.

La resto de la klaso Game konsistas el helpaj metodoj uzataj en update():

game.js parto 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() sufiĉe simple - ĝi ordigas la ludantojn laŭ poentaro, prenas la plej bonajn kvin, kaj resendas la uzantnomon kaj poentaron por ĉiu.

createUpdate() uzata en update() krei ludĝisdatigojn kiuj estas distribuitaj al ludantoj. Ĝia ĉefa tasko estas voki metodojn serializeForUpdate()efektivigita por klasoj Player и Bullet. Notu, ke ĝi nur pasas datumojn al ĉiu ludanto pri plej proksima ludantoj kaj ĵetaĵoj - ne necesas transdoni informojn pri ludobjektoj malproksime de la ludanto!

3. Ludaj objektoj sur la servilo

En nia ludo, ĵetaĵoj kaj ludantoj fakte tre similas: ili estas abstraktaj, rondaj, moveblaj ludobjektoj. Por utiligi ĉi tiun similecon inter ludantoj kaj ĵetaĵoj, ni komencu efektivigante la bazan klason Object:

objekto.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,
    };
  }
}

Ĉi tie okazas nenio komplika. Ĉi tiu klaso estos bona ankropunkto por la etendaĵo. Ni vidu kiel la klaso Bullet uzoj 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;
  }
}

Реализация Bullet tre mallonga! Ni aldonis al Object nur la sekvaj etendaĵoj:

  • Uzante pakaĵon mallongigita por hazarda generacio id kuglo.
  • Aldonante kampon parentIDpor ke vi povu spuri la ludanton, kiu kreis ĉi tiun ĵetaĵon.
  • Aldonante revenan valoron al update(), kiu egalas al truese la ĵetaĵo estas ekster la areno (memoru, ke ni parolis pri tio en la lasta sekcio?).

Ni transiru al Player:

ludanto.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,
    };
  }
}

Ludantoj estas pli kompleksaj ol ĵetaĵoj, do kelkaj pliaj kampoj estu stokitaj en ĉi tiu klaso. Lia metodo update() faras multe da laboro, precipe, resendas la nove kreitan ĵetaĵon se restas neniu fireCooldown (memoru, ke ni parolis pri tio en la antaŭa sekcio?). Ĝi ankaŭ etendas la metodon serializeForUpdate(), ĉar ni devas inkluzivi pliajn kampojn por la ludanto en la ludĝisdatigo.

Havante bazan klason Object - grava paŝo por eviti ripeti kodon. Ekzemple, neniu klaso Object ĉiu ludobjekto devas havi la saman efektivigon distanceTo(), kaj kopii-alglui ĉiujn ĉi tiujn efektivigojn tra pluraj dosieroj estus koŝmaro. Ĉi tio fariĝas speciale grava por grandaj projektoj.kiam la nombro de vastiganta Object klasoj kreskas.

4. Kolizio-detekto

Restas al ni nur rekoni, kiam la ĵetaĵoj trafas la ludantojn! Memoru ĉi tiun kodon el la metodo update() en klaso Game:

ludo.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),
    );

    // ...
  }
}

Ni devas efektivigi la metodon applyCollisions(), kiu resendas ĉiujn kuglojn kiuj trafas ludantojn. Feliĉe, ĝi ne estas tiel malfacile fari ĉar

  • Ĉiuj koliziantaj objektoj estas cirkloj, kio estas la plej simpla formo por efektivigi koliziodetekton.
  • Ni jam havas metodon distanceTo(), kiun ni efektivigis en la antaŭa sekcio en la klaso Object.

Jen kiel aspektas nia efektivigo de kolizio-detekto:

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

Ĉi tiu simpla kolizio-detekto baziĝas sur la fakto ke du cirkloj kolizias se la distanco inter iliaj centroj estas malpli ol la sumo de iliaj radiusoj. Jen la kazo kie la distanco inter la centroj de du cirkloj estas ekzakte egala al la sumo de iliaj radiusoj:

Kreante Plurludantan .io Retan Ludon
Estas kelkaj pliaj aspektoj por konsideri ĉi tie:

  • La kuglo ne devas trafi la ludanton kiu kreis ĝin. Ĉi tio povas esti atingita per komparo bullet.parentID с player.id.
  • La kuglo devas nur trafi unufoje en la limiga kazo de multoblaj ludantoj koliziantaj samtempe. Ni solvos ĉi tiun problemon uzante la operatoron break: tuj kiam la ludanto kolizianta kun la kuglo estas trovita, ni ĉesas la serĉon kaj transiras al la sekva kuglo.

La Fino

Tio estas ĉio! Ni kovris ĉion, kion vi bezonas scii por krei retludon .io. Kio sekvas? Konstruu vian propran ludon .io!

Ĉiu ekzempla kodo estas malfermfonta kaj afiŝita GitHub.

fonto: www.habr.com

Aldoni komenton