Ħolqien ta' Multiplayer .io Web Game

Ħolqien ta' Multiplayer .io Web Game
Maħruġ fl-2015 Agar.io sar il-proġenitur ta’ ġeneru ġdid logħob .ioli kibret fil-popolarità minn dakinhar. Jien personalment esperjenzajt iż-żieda fil-popolarità tal-logħob .io: matul l-aħħar tliet snin, għandi ħoloq u biegħ żewġ logħbiet ta’ dan il-ġeneru..

Fil-każ li qatt ma smajt b'dawn il-logħob qabel, dawn huma logħob tal-web multiplayer b'xejn li huma faċli biex tilgħab (l-ebda kont meħtieġ). Normalment jiffaċċjaw ħafna plejers avversarji fl-istess arena. Logħob .io famuż ieħor: Slither.io и Diep.io.

F'din il-kariga, se nesploraw kif toħloq logħba .io mill-bidu. Għal dan, l-għarfien tal-Javascript biss ikun biżżejjed: trid tifhem affarijiet bħas-sintassi ES6, keyword this и Wegħdiet. Anki jekk l-għarfien tiegħek ta 'Javascript mhuwiex perfett, xorta tista' tifhem ħafna mill-post.

Eżempju tal-logħba .io

Għall-għajnuna għat-tagħlim, se nirreferu għaliha Eżempju tal-logħba .io. Ipprova tilgħabha!

Ħolqien ta' Multiplayer .io Web Game
Il-logħba hija pjuttost sempliċi: inti tikkontrolla vapur f'arena fejn hemm plejers oħra. Il-vapur tiegħek awtomatikament jispara l-projettili u inti tipprova tolqot plejers oħra filwaqt li tevita l-projettili tagħhom.

1. Ħarsa ġenerali qasira / struttura tal-proġett

Irrakkomanda niżżel il-kodiċi tas-sors logħba eżempju sabiex inti tista 'ssegwi lili.

L-eżempju juża dan li ġej:

  • Express huwa l-aktar popolari web framework Node.js li jamministra s-server tal-web tal-logħba.
  • socket.io - librerija tal-websocket għall-iskambju tad-dejta bejn browser u server.
  • Webpack - maniġer tal-modulu. Tista' taqra għaliex tuża Webpack. hawn.

Hawn kif tidher l-istruttura tad-direttorju tal-proġett:

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

pubbliku/

Kollox f'folder public/ se jiġu sottomessi statikament mis-server. IN public/assets/ fih immaġini użati mill-proġett tagħna.

src /

Il-kodiċi tas-sors kollu jinsab fil-folder src/. Titoli client/ и server/ jitkellmu waħedhom u shared/ fih fajl tal-kostanti li huwa importat kemm mill-klijent kif ukoll mis-server.

2. Assemblaġġi/issettjar tal-proġett

Kif imsemmi hawn fuq, nużaw il-maniġer tal-modulu biex nibnu l-proġett. Webpack. Ejja nagħtu ħarsa lejn il-konfigurazzjoni tal-Webpack tagħna:

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

L-aktar linji importanti hawn huma:

  • src/client/index.js huwa l-punt tad-dħul tal-klijent Javascript (JS). Webpack se jibda minn hawn u jfittex b'mod rikorsis għal fajls importati oħra.
  • L-output JS tal-bini tal-Webpack tagħna se jkun jinsab fid-direttorju dist/. Se nsejjaħ dan il-fajl tagħna pakkett js.
  • Aħna nużaw Babel, u b'mod partikolari l-konfigurazzjoni @babel/preset-env biex tittraspila l-kodiċi JS tagħna għal browsers anzjani.
  • Qed nużaw plugin biex niġbdu s-CSS kollha referenzjati mill-fajls JS u ngħaqqduhom f'post wieħed. Se nsejjaħlu tagħna pakkett css.

Jista 'jkollok innotajt ismijiet ta' fajls ta 'pakketti strambi '[name].[contenthash].ext'. Fihom sostituzzjonijiet tal-ismijiet tal-fajls webpack: [name] se jiġi sostitwit bl-isem tal-punt tal-input (fil-każ tagħna, dan game), u [contenthash] se jiġi sostitwit b'hash tal-kontenut tal-fajl. Nagħmluha biex ottimizza l-proġett għall-hashing - tista 'tgħid lill-browsers biex jaħtfu l-pakketti JS tagħna b'mod indefinit, għaliex jekk jinbidel pakkett, allura l-isem tal-fajl tiegħu jinbidel ukoll (bidliet contenthash). Ir-riżultat finali se jkun l-isem tal-fajl tal-vista game.dbeee76e91a97d0c7207.js.

fajl webpack.common.js huwa l-fajl tal-konfigurazzjoni bażi li aħna importazzjoni fl-iżvilupp u l-konfigurazzjonijiet tal-proġett lest. Hawnhekk hawn eżempju ta 'konfigurazzjoni ta' żvilupp:

webpack.dev.js

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

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

Għall-effiċjenza, nużaw fil-proċess ta 'żvilupp webpack.dev.js, u jaqleb għal webpack.prod.jsbiex jottimizzaw id-daqsijiet tal-pakketti meta jiġu skjerati għall-produzzjoni.

Issettjar lokali

Nirrakkomanda li tinstalla l-proġett fuq magna lokali sabiex tkun tista 'ssegwi l-passi elenkati f'din il-kariga. Is-setup huwa sempliċi: l-ewwel, is-sistema trid tkun installata nodu и NPM. Sussegwentement trid tagħmel

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

u inti lest biex tmur! Biex tibda s-server tal-iżvilupp, ħadmu biss

$ npm run develop

u mur fil-web browser localhost: 3000. Is-server tal-iżvilupp awtomatikament jerġa 'jibni l-pakketti JS u CSS hekk kif il-kodiċi jinbidel - sempliċement aġġorna l-paġna biex tara l-bidliet kollha!

3. Punti tad-Dħul tal-Klijent

Ejja niżlu għall-kodiċi tal-logħba innifsu. L-ewwel għandna bżonn paġna index.html, meta żżur is-sit, il-browser se jgħabbih l-ewwel. Il-paġna tagħna se tkun pjuttost sempliċi:

index.html

Eżempju .io logħba  PLAY

Dan l-eżempju tal-kodiċi ġie ssimplifikat kemmxejn għaċ-ċarezza, u jien se nagħmel l-istess ma 'ħafna mill-eżempji l-oħra tal-post. Il-kodiċi sħiħ jista' dejjem jaraha fuq GitHub.

Għandna:

  • Element tal-kanvas HTML5 (<canvas>) li se nużaw biex nirrendu l-logħba.
  • <link> biex iżżid il-pakkett CSS tagħna.
  • <script> biex iżżid il-pakkett Javascript tagħna.
  • Menu prinċipali bl-isem tal-utent <input> u l-buttuna PLAY (<button>).

Wara li tgħabbi l-home page, il-browser jibda jesegwixxi kodiċi Javascript, u ​​jibda mill-fajl JS tal-punt tad-dħul: 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);
  };
});

Dan jista 'jidher ikkumplikat, iżda m'hemm xejn għaddej hawn:

  1. Importazzjoni ta 'diversi fajls JS oħra.
  2. Importazzjoni CSS (hekk Webpack ikun jaf li jinkludihom fil-pakkett CSS tagħna).
  3. Tnedija connect() biex tistabbilixxi konnessjoni mas-server u run downloadAssets() biex tniżżel immaġini meħtieġa biex tirrendi l-logħba.
  4. Wara t-tlestija tal-istadju 3 jintwera l-menu prinċipali (playMenu).
  5. L-issettjar tal-handler biex tagħfas il-buttuna "PLAY". Meta l-buttuna tiġi ppressata, il-kodiċi jinizjalizza l-logħba u jgħid lis-server li aħna lesti biex nilagħbu.

Il-"laħam" ewlieni tal-loġika tal-klijent-server tagħna jinsab f'dawk il-fajls li ġew importati mill-fajl index.js. Issa se nqisuhom kollha fl-ordni.

4. Skambju ta' data tal-klijenti

F'din il-logħba, nużaw librerija magħrufa sew biex nikkomunikaw mas-server socket.io. Socket.io għandu appoġġ nattiv sokits tal-web, li huma adattati tajjeb għal komunikazzjoni f'żewġ direzzjonijiet: nistgħu nibagħtu messaġġi lis-server и is-server jista 'jibgħatilna messaġġi fuq l-istess konnessjoni.

Se jkollna fajl wieħed src/client/networking.jsmin se jieħu ħsieb kulħadd komunikazzjoni mas-server:

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

Dan il-kodiċi tqassar ukoll ftit għal ċarezza.

Hemm tliet azzjonijiet ewlenin f'dan il-fajl:

  • Qed nippruvaw nikkonnettjaw mas-server. connectedPromise permess biss meta waqqafna konnessjoni.
  • Jekk il-konnessjoni tirnexxi, nirreġistraw funzjonijiet ta' callback (processGameUpdate() и onGameOver()) għal messaġġi li nistgħu nirċievu mis-server.
  • Aħna l-esportazzjoni play() и updateDirection()sabiex fajls oħra jkunu jistgħu jużawhom.

5. Rendiment tal-Klijent

Wasal iż-żmien li turi l-istampa fuq l-iskrin!

...iżda qabel ma nkunu nistgħu nagħmlu dan, irridu tniżżel l-immaġini (riżorsi) kollha li huma meħtieġa għal dan. Ejja niktbu maniġer tar-riżorsi:

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

Il-ġestjoni tar-riżorsi mhix daqshekk diffiċli biex timplimenta! L-idea ewlenija hija li taħżen oġġett assets, li se jorbot iċ-ċavetta tal-isem tal-fajl mal-valur tal-oġġett Image. Meta r-riżorsa titgħabba, aħna naħżnuha f'oġġett assets għal aċċess rapidu fil-futur. Meta se titħalla tniżżel kull riżors individwali (jiġifieri, kollha riżorsi), nippermettu downloadPromise.

Wara li tniżżel ir-riżorsi, tista 'tibda tirrendi. Kif intqal qabel, biex tiġbed fuq paġna web, nużaw Kanvas HTML5 (<canvas>). Il-logħba tagħna hija pjuttost sempliċi, għalhekk irridu niġbdu biss dan li ġej:

  1. Sfond
  2. Vapur tal-plejer
  3. Plejers oħra fil-logħba
  4. Qxur

Hawn huma l-snippets importanti src/client/render.js, li jirrendu eżattament l-erba' oġġetti elenkati hawn fuq:

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

Dan il-kodiċi huwa wkoll imqassar għaċ-ċarezza.

render() hija l-funzjoni ewlenija ta' dan il-fajl. startRendering() и stopRendering() tikkontrolla l-attivazzjoni tar-render loop f'60 FPS.

Implimentazzjonijiet konkreti ta’ funzjonijiet individwali ta’ għoti ta’ għajnuna (eż. renderBullet()) mhumiex daqshekk importanti, iżda hawn eżempju wieħed sempliċi:

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

Innota li qed nużaw il-metodu getAsset(), li qabel kienet tidher fi asset.js!

Jekk int interessat li titgħallem dwar helpers oħra tar-rendi, imbagħad aqra l-bqija. src/client/render.js.

6. Input tal-klijent

Wasal iż-żmien li tagħmel logħba jintlagħab! L-iskema ta 'kontroll se tkun sempliċi ħafna: biex tibdel id-direzzjoni tal-moviment, tista' tuża l-maws (fuq kompjuter) jew tmiss l-iskrin (fuq apparat mobbli). Biex nimplimentaw dan, aħna se nirreġistraw Semmiegħa tal-Avvenimenti għal avvenimenti Mouse u Touch.
Se jieħu ħsieb dan kollu 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() huma Event Listeners li jċemplu updateDirection() (ta ' networking.js) meta jseħħ avveniment ta' input (per eżempju, meta l-maws jiġi mċaqlaq). updateDirection() jimmaniġġja l-messaġġi mas-server, li jieħu ħsieb l-avveniment tal-input u jaġġorna l-istat tal-logħba kif xieraq.

7. Status tal-Klijent

Din it-taqsima hija l-aktar diffiċli fl-ewwel parti tal-post. Taqtax qalbek jekk ma tifhimx l-ewwel darba li taqrah! Tista 'saħansitra taqbeżha u terġa' lura għaliha aktar tard.

L-aħħar biċċa tal-puzzle meħtieġa biex tlesti l-kodiċi tal-klijent/server hija kienu. Ftakar is-snippet tal-kodiċi mit-taqsima tal-Klijent Rendering?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() għandhom ikunu jistgħu jagħtuna l-istat attwali tal-logħba fil-klijent fi kwalunkwe punt fiż-żmien ibbażat fuq aġġornamenti riċevuti mis-server. Hawn eżempju ta 'aġġornament tal-logħba li s-server jista' jibgħat:

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

Kull aġġornament tal-logħba fih ħames oqsma identiċi:

  • t: Timestamp tas-server li jindika meta nħoloq dan l-aġġornament.
  • me: Informazzjoni dwar il-plejer li qed jirċievi dan l-aġġornament.
  • oħrajn: Firxa ta' informazzjoni dwar plejers oħra li qed jipparteċipaw fl-istess logħba.
  • balal: firxa ta 'informazzjoni dwar projettili fil-logħba.
  • leaderboard: Dejta kurrenti tal-leaderboard. F'din il-kariga, mhux se nqisuhom.

7.1 Stat naive tal-klijent

Implimentazzjoni naive getCurrentState() jista 'biss jirritorna direttament id-dejta tal-aġġornament tal-logħba l-aktar riċentement riċevut.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Sbieħ u ċar! Imma kieku kien daqshekk sempliċi. Waħda mir-raġunijiet għal din l-implimentazzjoni hija problematika: tillimita r-rata tal-frejms tar-rendiment għar-rata tal-arloġġ tas-server.

Frame Rata: numru ta’ frames (jiġifieri sejħiet render()) kull sekonda, jew FPS. Logħob normalment jistinkaw biex jiksbu mill-inqas 60 FPS.

Immarka Rata: Il-frekwenza li biha s-server jibgħat aġġornamenti tal-logħob lill-klijenti. Ħafna drabi huwa aktar baxx mir-rata tal-qafas. Fil-logħba tagħna, is-server jaħdem bi frekwenza ta '30 ċiklu kull sekonda.

Jekk aħna biss jirrendu l-aħħar aġġornament tal-logħba, allura l-FPS essenzjalment qatt mhu se jmur aktar minn 30, għaliex aħna qatt ma niksbu aktar minn 30 aġġornamenti kull sekonda mis-server. Anke jekk insejħu render() 60 darba fis-sekonda, allura nofs dawn is-sejħiet se jiġbed mill-ġdid l-istess ħaġa, essenzjalment ma jagħmlu xejn. Problema oħra bl-implimentazzjoni naive hija li dan suxxettibbli għal dewmien. B'veloċità ideali tal-Internet, il-klijent jirċievi aġġornament tal-logħba eżattament kull 33ms (30 kull sekonda):

Ħolqien ta' Multiplayer .io Web Game
Sfortunatament, xejn mhu perfett. Stampa aktar realistika tkun:
Ħolqien ta' Multiplayer .io Web Game
L-implimentazzjoni naive hija prattikament l-agħar każ fejn tidħol latency. Jekk jiġi riċevut aġġornament tal-logħba b'dewmien ta '50ms, allura tilari tal-klijenti 50ms żejda minħabba li għadha tirrendi l-istat tal-logħba mill-aġġornament preċedenti. Tista 'timmaġina kemm dan huwa skomdu għall-plejer: ibbrejkjar arbitrarju se jagħmel il-logħba tħossok jerky u instabbli.

7.2 Stat imtejjeb tal-klijent

Se nagħmlu xi titjib fl-implimentazzjoni naive. L-ewwel, nużaw dewmien tal-għoti għal 100 ms. Dan ifisser li l-istat "kurrenti" tal-klijent dejjem se jibqa 'wara l-istat tal-logħba fuq is-server b'100ms. Per eżempju, jekk il-ħin fuq is-server huwa 150, allura l-klijent jirrendi l-istat li fih kien is-server dak iż-żmien 50:

Ħolqien ta' Multiplayer .io Web Game
Dan jagħtina buffer ta' 100 ms biex ngħixu ħinijiet imprevedibbli tal-aġġornament tal-logħob:

Ħolqien ta' Multiplayer .io Web Game
Il-ħlas għal dan se jkun permanenti dewmien tad-dħul għal 100 ms. Dan huwa sagrifiċċju minuri għal gameplay bla xkiel - il-biċċa l-kbira tal-plejers (speċjalment plejers każwali) lanqas biss se jindunaw b'dan id-dewmien. Huwa ħafna aktar faċli għan-nies li jaġġustaw għal latency kostanti ta '100ms milli jilagħbu b'latency imprevedibbli.

Nistgħu wkoll nużaw teknika oħra msejħa tbassir min-naħa tal-klijent, li tagħmel xogħol tajjeb biex tnaqqas il-latenza perċepita, iżda mhux se tkun koperta f'din il-kariga.

Titjib ieħor li qed nużaw hu interpolazzjoni lineari. Minħabba dewmien tar-rendi, aħna ġeneralment mill-inqas aġġornament wieħed qabel il-ħin attwali fil-klijent. Meta tissejjaħ getCurrentState(), nistgħu tesegwixxi interpolazzjoni lineari bejn l-aġġornamenti tal-logħob eżatt qabel u wara l-ħin attwali fil-klijent:

Ħolqien ta' Multiplayer .io Web Game
Dan isolvi l-kwistjoni tar-rata tal-frejms: issa nistgħu nirrendu frejms uniċi fi kwalunkwe rata tal-qafas li rridu!

7.3 L-implimentazzjoni tal-istat tal-klijent imtejjeb

Eżempju ta’ implimentazzjoni fi src/client/state.js juża kemm render lag kif ukoll interpolazzjoni lineari, iżda mhux għal żmien twil. Ejja naqsmu l-kodiċi f'żewġ partijiet. Hawn l-ewwel waħda:

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

L-ewwel pass huwa biex insemmu xiex currentServerTime(). Kif rajna qabel, kull aġġornament tal-logħba jinkludi timestamp tas-server. Irridu nużaw render latency biex tirrendi l-immaġni 100ms wara s-server, iżda qatt ma se nkunu nafu l-ħin attwali fuq is-server, għax ma nistgħux inkunu nafu kemm damu biex xi wieħed mill-aġġornamenti wasalna. L-Internet huwa imprevedibbli u l-veloċità tiegħu tista 'tvarja ħafna!

Biex nersqu din il-problema, nistgħu nużaw approssimazzjoni raġonevoli: aħna nippretendu li l-ewwel aġġornament wasal istantanjament. Kieku dan kien minnu, allura nkunu nafu l-ħin tas-server f'dan il-mument partikolari! Aħna naħżnu l-timestamp tas-server firstServerTimestamp u żomm tagħna lokali (klijent) timestamp fl-istess mument fi gameStart.

Oh stenna. M'għandux ikun ħin tas-server = ħin tal-klijent? Għaliex niddistingwu bejn "timestamp tas-server" u "timestamp tal-klijent"? Din hija mistoqsija kbira! Jirriżulta li mhumiex l-istess ħaġa. Date.now() se jirritorna timestamps differenti fil-klijent u s-server, u jiddependi fuq fatturi lokali għal dawn il-magni. Qatt tassumi li timestamps se jkunu l-istess fuq il-magni kollha.

Issa nifhmu x'jagħmel currentServerTime(): jirritorna il-timestamp tas-server tal-ħin tar-rendi kurrenti. Fi kliem ieħor, dan huwa l-ħin attwali tas-server (firstServerTimestamp <+ (Date.now() - gameStart)) nieqes id-dewmien tar-rendi (RENDER_DELAY).

Issa ejja nagħtu ħarsa lejn kif nittrattaw l-aġġornamenti tal-logħob. Meta tasal mis-server tal-aġġornament, tissejjaħ processGameUpdate()u niffrankaw l-aġġornament il-ġdid għal firxa gameUpdates. Imbagħad, biex tivverifika l-użu tal-memorja, inneħħu l-aġġornamenti qodma kollha qabel aġġornament tal-bażigħax m’għandniex bżonnhom aktar.

X'inhu "aġġornament bażiku"? Dan l-ewwel aġġornament insibu billi nimxu lura mill-ħin attwali tas-server. Tiftakar din id-dijagramma?

Ħolqien ta' Multiplayer .io Web Game
L-aġġornament tal-logħba direttament fuq ix-xellug tal-"Ħin tar-Rendiment tal-Klijent" huwa l-aġġornament bażi.

Għal xiex jintuża l-aġġornament bażi? Għaliex nistgħu nħallu aġġornamenti għal-linja bażi? Biex insemmu dan, ejja finalment tikkunsidra l-implimentazzjoni getCurrentState():

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

Aħna nittrattaw tliet każijiet:

  1. base < 0 ifisser li m'hemm l-ebda aġġornamenti sal-ħin attwali tar-rendi (ara l-implimentazzjoni ta 'hawn fuq getBaseUpdate()). Dan jista 'jiġri eżatt fil-bidu tal-logħba minħabba dewmien tar-rendi. F'dan il-każ, nużaw l-aħħar aġġornament li wasal.
  2. base huwa l-aħħar aġġornament li għandna. Dan jista' jkun minħabba dewmien tan-netwerk jew konnessjoni ħażina tal-Internet. F'dan il-każ, qed nużaw ukoll l-aħħar aġġornament li għandna.
  3. Għandna aġġornament kemm qabel kif ukoll wara l-ħin attwali tar-rendi, sabiex inkunu nistgħu interpola!

Dak kollu li fadal ġewwa state.js hija implimentazzjoni ta 'interpolazzjoni lineari li hija matematika sempliċi (iżda boring). Jekk trid tesploraha lilek innifsek, imbagħad iftaħ state.js fuq GitHub.

Parti 2. Server backend

F'din il-parti, aħna ser nagħtu ħarsa lejn il-backend Node.js li jikkontrolla tagħna Eżempju tal-logħba .io.

1. Punt tad-Dħul tas-Server

Biex timmaniġġja s-server tal-web, se nużaw qafas tal-web popolari għal Node.js imsejjaħ Express. Se jiġi kkonfigurat mill-fajl tal-punt tad-dħul tas-server tagħna src/server/server.js:

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

Ftakar li fl-ewwel parti iddiskutejna Webpack? Dan huwa fejn se nużaw il-konfigurazzjonijiet tal-Webpack tagħna. Aħna se nużawhom b'żewġ modi:

  • Uża webpack-dev-middleware biex awtomatikament nibnu mill-ġdid il-pakketti tal-iżvilupp tagħna, jew
  • trasferiment statiku folder dist/, li fih Webpack se jikteb il-fajls tagħna wara l-bini tal-produzzjoni.

Kompitu ieħor importanti server.js huwa li twaqqaf is-server socket.ioli tikkonnettja biss mas-server Express:

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

Wara li stabbilixxa b'suċċess konnessjoni socket.io mas-server, waqqafna handlers tal-avvenimenti għas-socket il-ġdid. L-amministraturi tal-avvenimenti jimmaniġġjaw messaġġi riċevuti mill-klijenti billi jiddelegaw lil oġġett singleton game:

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

Qed noħolqu logħba .io, għalhekk għandna bżonn kopja waħda biss Game ("Logħba") - il-plejers kollha jilagħbu fl-istess arena! Fit-taqsima li jmiss, se naraw kif taħdem din il-klassi. Game.

2. Servers tal-logħob

Klassi Game fih l-aktar loġika importanti fuq in-naħa tas-server. Għandu żewġ kompiti ewlenin: ġestjoni tal-plejers и simulazzjoni tal-logħob.

Nibdew bl-ewwel kompitu, il-ġestjoni tal-plejers.

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

  // ...
}

F'din il-logħba, aħna se nidentifikaw il-plejers mill-qasam id is-socket socket.io tagħhom (jekk titħawwad, imbagħad mur lura għal server.js). Socket.io innifsu jassenja kull socket uniku idgħalhekk m'għandniex bżonn ninkwetaw dwar dan. Se nsejjaħlu ID tal-plejer.

B'dan f'moħħna, ejja nesploraw istanza varjabbli fi klassi Game:

  • sockets huwa oġġett li jorbot l-ID tal-plejer mas-socket li huwa assoċjat mal-plejer. Jippermettilna naċċessaw sokits mill-IDs tal-plejers tagħhom f'ħin kostanti.
  • players huwa oġġett li jorbot l-ID tal-plejer mal-kodiċi>Oġġett tal-plejer

bullets hija firxa ta 'oġġetti Bullet, li m'għandha l-ebda ordni definit.
lastUpdateTime hija l-timestamp tal-aħħar darba li l-logħba ġiet aġġornata. Naraw kif tintuża dalwaqt.
shouldSendUpdate hija varjabbli awżiljarju. Se naraw ukoll l-użu tiegħu dalwaqt.
Metodi addPlayer(), removePlayer() и handleInput() m'hemmx għalfejn tispjega, huma użati fi server.js. Jekk għandek bżonn iġedded il-memorja tiegħek, mur lura ftit ogħla.

L-aħħar linja constructor() jibda ċiklu ta' aġġornament logħob (bi frekwenza ta' 60 aġġornament / i):

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

  // ...
}

Metodu update() fih forsi l-aktar biċċa importanti tal-loġika tas-server. Hawn x'tagħmel, fl-ordni:

  1. Jikkalkula kemm idum dt għadda mill-aħħar update().
  2. Aġġorna kull projettili u jeqredhom jekk meħtieġ. Se naraw l-implimentazzjoni ta 'din il-funzjonalità aktar tard. Għalissa, huwa biżżejjed li nkunu nafu dan bullet.update() prospetti truejekk il-projettili għandu jinqered (huwa ħareġ mill-arena).
  3. Jaġġorna kull plejer u jbid projettili jekk meħtieġ. Din l-implimentazzjoni se naraw ukoll aktar tard − player.update() jista 'jirritorna oġġett Bullet.
  4. Kontrolli għal ħabtiet bejn projettili u plejers ma applyCollisions(), li jirritorna firxa ta 'projettili li laqtu lill-plejers. Għal kull projettili lura, inżidu l-punti tal-plejer li sparaha (bl-użu player.onDealtDamage()) u mbagħad neħħi l-projettili mill-firxa bullets.
  5. Javża u jeqred lill-plejers kollha maqtula.
  6. Tibgħat aġġornament tal-logħba lill-plejers kollha kull sekonda drabi meta tissejjaħ update(). Dan jgħinna nżommu rekord tal-varjabbli awżiljarju msemmi hawn fuq. shouldSendUpdate. Għax update() imsejħa 60 darba/s, nibagħtu aġġornamenti tal-logħob 30 darba/s. Għalhekk, frekwenza tal-arloġġ arloġġ tas-server huwa 30 arloġġi/s (tkellimna dwar ir-rati tal-arloġġ fl-ewwel parti).

Għaliex tibgħat aġġornamenti tal-logħob biss matul iż-żmien ? Biex tiffranka l-kanal. 30 aġġornament tal-logħob kull sekonda huwa ħafna!

Għaliex mhux sempliċiment iċempel update() 30 darba kull sekonda? Biex ittejjeb is-simulazzjoni tal-logħba. L-aktar spiss imsejħa update(), iktar tkun preċiża s-simulazzjoni tal-logħba. Imma titlaqx wisq bin-numru ta’ sfidi. update(), minħabba li dan huwa kompitu li jiswa ħafna flus - 60 kull sekonda huwa biżżejjed.

Il-bqija tal-klassi Game tikkonsisti minn metodi helper użati fl update():

game.js parti 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() pjuttost sempliċi - issortja l-plejers skond il-punteġġ, jieħu l-aqwa ħamsa, u jirritorna l-username u l-punteġġ għal kull wieħed.

createUpdate() użat fi update() biex toħloq aġġornamenti tal-logħob li jitqassmu lill-plejers. Il-kompitu ewlieni tiegħu huwa li jsejjaħ metodi serializeForUpdate()implimentati għall-klassijiet Player и Bullet. Innota li tgħaddi biss data lil kull plejer dwar l-eqreb plejers u projettili - m'hemmx bżonn li tittrasmetti informazzjoni dwar oġġetti tal-logħob li huma 'l bogħod mill-plejer!

3. Oġġetti tal-logħob fuq is-server

Fil-logħba tagħna, il-projettili u l-plejers huma fil-fatt simili ħafna: huma oġġetti tal-logħob astratti, tondi u mobbli. Biex tieħu vantaġġ minn din ix-xebh bejn il-plejers u l-projettili, ejja nibdew billi nimplimentaw il-klassi bażi 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,
    };
  }
}

M'hemm xejn ikkumplikat għaddej hawn. Din il-klassi se tkun punt ta 'ankra tajba għall-estensjoni. Ejja naraw kif il-klassi Bullet użi 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 qasir ħafna! Żiedna Object l-estensjonijiet li ġejjin biss:

  • Bl-użu ta 'pakkett shortid għall-ġenerazzjoni każwali id projettili.
  • Żieda ta 'qasam parentIDsabiex inti tista 'ssegwi l-plejer li ħoloq dan il-projettili.
  • Żieda ta 'valur ta' ritorn għal update(), li hija ugwali għal truejekk il-projettili huwa barra l-arena (tiftakar li tkellimna dwar dan fl-aħħar taqsima?).

Ejja nimxu fuq 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,
    };
  }
}

Il-plejers huma aktar kumplessi mill-projettili, għalhekk ftit aktar oqsma għandhom jinħażnu f'din il-klassi. Il-metodu tiegħu update() jagħmel ħafna xogħol, b'mod partikolari, jirritorna l-projettili li għadu kif inħoloq jekk ma jkun fadal xejn fireCooldown (tiftakar li tkellimna dwar dan fit-taqsima preċedenti?). Ukoll jestendi l-metodu serializeForUpdate(), għax għandna bżonn ninkludu oqsma addizzjonali għall-plejer fl-aġġornament tal-logħba.

Li jkollok klassi bażi Object - pass importanti biex tevita li tirrepeti l-kodiċi. Per eżempju, l-ebda klassi Object kull oġġett tal-logħba għandu jkollu l-istess implimentazzjoni distanceTo(), u l-ikkupjar ta' dawn l-implimentazzjonijiet kollha fuq fajls multipli jkun ħmar il-lejl. Dan isir speċjalment importanti għal proġetti kbar.meta n-numru ta 'espansjoni Object il-klassijiet qed jikbru.

4. Sejbien ta 'ħabtiet

L-unika ħaġa li fadal għalina hija li nagħrfu meta l-projettili laqtu lill-plejers! Ftakar din il-biċċa kodiċi mill-metodu update() fil-klassi 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),
    );

    // ...
  }
}

Għandna bżonn nimplimentaw il-metodu applyCollisions(), li jirritorna l-projettili kollha li laqtu lill-plejers. Fortunatament, mhuwiex daqshekk diffiċli li tagħmel għaliex

  • L-oġġetti kollha li jolqtu huma ċrieki, u din hija l-aktar forma sempliċi biex timplimenta l-iskoperta tal-ħabta.
  • Diġà għandna metodu distanceTo(), li implimentajna fit-taqsima preċedenti fil-klassi Object.

Hawn hu kif tidher l-implimentazzjoni tagħna tas-sejbien tal-ħabtiet:

ħabtiet.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;
}

Dan l-iskoperta ta 'ħabta sempliċi hija bbażata fuq il-fatt li żewġ ċrieki jaħbtu jekk id-distanza bejn iċ-ċentri tagħhom hija inqas mis-somma tar-raġġi tagħhom. Hawn hu l-każ fejn id-distanza bejn iċ-ċentri ta’ żewġ ċrieki hija eżattament ugwali għas-somma tar-raġġi tagħhom:

Ħolqien ta' Multiplayer .io Web Game
Hemm ftit aspetti oħra li għandek tikkonsidra hawn:

  • Il-projettili m'għandux jolqot lill-plejer li ħoloqha. Dan jista 'jinkiseb billi jitqabbel bullet.parentID с player.id.
  • Il-projettili għandu jolqot darba biss fil-każ li jillimita l-atturi multipli jaħbtu fl-istess ħin. Aħna se ssolvi din il-problema billi tuża l-operatur break: malli jinstab il-plejer li jaħbat mal-projettili, nieqfu t-tfittxija u ngħaddu għall-projettili li jmiss.

Tmiem

Dak kollox! Aħna koprejna dak kollu li għandek bżonn tkun taf biex toħloq logħba tal-web .io. X'inhu jmiss? Ibni l-logħba .io tiegħek!

Il-kodiċi kollu tal-kampjun huwa sors miftuħ u mibgħut fuq GitHub.

Sors: www.habr.com

Żid kumment