Paglikha ng Multiplayer .io Web Game

Paglikha ng Multiplayer .io Web Game
Inilabas noong 2015 agar.io naging ninuno ng isang bagong genre laro .iona lumago sa katanyagan mula noon. Personal kong naranasan ang pagtaas ng katanyagan ng mga larong .io: sa nakalipas na tatlong taon, mayroon ako lumikha at nagbebenta ng dalawang laro ng ganitong genre..

Kung sakaling hindi mo pa narinig ang mga larong ito dati, ito ay mga libreng multiplayer web game na madaling laruin (walang kinakailangang account). Kadalasan ay nakakaharap nila ang maraming kalabang manlalaro sa parehong arena. Iba pang sikat na .io na laro: Slither.io ΠΈ Diep.io.

Sa post na ito, tutuklasin natin kung paano lumikha ng isang .io na laro mula sa simula. Para dito, sapat na ang kaalaman sa Javascript: kailangan mong maunawaan ang mga bagay tulad ng syntax ES6, keyword this ΠΈ Mga pangako. Kahit na hindi perpekto ang iyong kaalaman sa Javascript, maiintindihan mo pa rin ang karamihan sa post.

Halimbawa ng larong .io

Para sa tulong sa pag-aaral, sasangguni kami sa Halimbawa ng larong .io. Subukang laruin ito!

Paglikha ng Multiplayer .io Web Game
Ang laro ay medyo simple: kinokontrol mo ang isang barko sa isang arena kung saan may iba pang mga manlalaro. Awtomatikong nagpapaputok ng projectiles ang iyong barko at sinubukan mong tamaan ang ibang mga manlalaro habang iniiwasan ang kanilang mga projectiles.

1. Maikling pangkalahatang-ideya / istraktura ng proyekto

irekomenda i-download ang source code halimbawa ng laro para masundan mo ako.

Ang halimbawa ay gumagamit ng sumusunod:

  • Ekspres ay ang pinakasikat na Node.js web framework na namamahala sa web server ng laro.
  • socket.io - isang websocket library para sa pagpapalitan ng data sa pagitan ng isang browser at isang server.
  • Webpack - manager ng module. Maaari mong basahin ang tungkol sa kung bakit dapat gamitin ang Webpack. dito.

Narito ang hitsura ng istraktura ng direktoryo ng proyekto:

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

publiko/

Lahat sa isang folder public/ ay static na isusumite ng server. SA public/assets/ naglalaman ng mga larawang ginamit ng aming proyekto.

src /

Ang lahat ng source code ay nasa folder src/. Mga pamagat client/ ΠΈ server/ magsalita para sa kanilang sarili at shared/ naglalaman ng isang constants file na ini-import ng parehong client at ng server.

2. Mga setting ng pagtitipon/proyekto

Gaya ng nabanggit sa itaas, ginagamit namin ang module manager para buuin ang proyekto. Webpack. Tingnan natin ang aming configuration sa Webpack:

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

Ang pinakamahalagang linya dito ay:

  • src/client/index.js ay ang entry point ng Javascript (JS) client. Magsisimula ang Webpack mula dito at muling maghanap para sa iba pang mga na-import na file.
  • Ang output JS ng aming Webpack build ay matatagpuan sa direktoryo dist/. Tatawagin ko itong file na aming js package.
  • Gumagamit kami Babel, at lalo na ang pagsasaayos @babel/preset-env sa paglipat ng aming JS code para sa mas lumang mga browser.
  • Gumagamit kami ng isang plugin upang kunin ang lahat ng CSS na isinangguni ng mga JS file at pagsamahin ang mga ito sa isang lugar. Tatawagin ko siyang aming css package.

Maaaring napansin mo ang kakaibang mga filename ng package '[name].[contenthash].ext'. Naglalaman ang mga ito pagpapalit ng filename webpack: [name] ay papalitan ng pangalan ng input point (sa aming kaso, ito game), at [contenthash] ay papalitan ng hash ng mga nilalaman ng file. Ginagawa namin to i-optimize ang proyekto para sa pag-hash - maaari mong sabihin sa mga browser na i-cache ang aming mga JS package nang walang katapusan, dahil kung magbabago ang isang package, magbabago rin ang pangalan ng file nito (mga pagbabago contenthash). Ang huling resulta ay ang pangalan ng view file game.dbeee76e91a97d0c7207.js.

talaksan webpack.common.js ay ang base configuration file na ini-import namin sa pagbuo at natapos na mga configuration ng proyekto. Narito ang isang halimbawa ng configuration ng development:

webpack.dev.js

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

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

Para sa kahusayan, ginagamit namin sa proseso ng pag-unlad webpack.dev.js, at lumipat sa webpack.prod.jsupang i-optimize ang mga laki ng package kapag nagde-deploy sa produksyon.

Lokal na setting

Inirerekomenda ko ang pag-install ng proyekto sa isang lokal na makina para masundan mo ang mga hakbang na nakalista sa post na ito. Ang setup ay simple: una, ang system ay dapat na naka-install Node ΠΈ NPM. Susunod na kailangan mong gawin

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

at handa ka nang umalis! Para simulan ang development server, tumakbo lang

$ npm run develop

at pumunta sa web browser localhost: 3000. Awtomatikong muling bubuuin ng development server ang JS at CSS packages habang nagbabago ang code - i-refresh lang ang page para makita ang lahat ng pagbabago!

3. Mga Puntos sa Pagpasok ng Kliyente

Bumaba tayo sa game code mismo. Una kailangan namin ng isang pahina index.html, kapag bumibisita sa site, ilo-load muna ito ng browser. Ang aming pahina ay magiging medyo simple:

index.html

Isang halimbawa ng larong .io  MAGLARO

Ang halimbawa ng code na ito ay pinasimple nang bahagya para sa kalinawan, at gagawin ko ang pareho sa marami sa iba pang mga halimbawa ng post. Ang buong code ay palaging makikita sa Github.

Meron kami:

  • HTML5 na elemento ng canvas (<canvas>) na gagamitin namin para i-render ang laro.
  • <link> upang idagdag ang aming CSS package.
  • <script> upang idagdag ang aming Javascript package.
  • Pangunahing menu na may username <input> at ang PLAY button (<button>).

Pagkatapos i-load ang home page, magsisimula ang browser sa pagpapatupad ng Javascript code, simula sa entry point JS file: 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);
  };
});

Ito ay maaaring mukhang kumplikado, ngunit walang gaanong nangyayari dito:

  1. Pag-import ng ilang iba pang mga JS file.
  2. CSS import (kaya alam ng Webpack na isama ang mga ito sa aming CSS package).
  3. Ilunsad connect() upang magtatag ng isang koneksyon sa server at tumakbo downloadAssets() para mag-download ng mga larawang kailangan para i-render ang laro.
  4. Matapos makumpleto ang yugto 3 ang pangunahing menu ay ipinapakita (playMenu).
  5. Pagtatakda ng handler para sa pagpindot sa "PLAY" na buton. Kapag pinindot ang button, ang code ay magsisimula ng laro at sasabihin sa server na handa na kaming maglaro.

Ang pangunahing "karne" ng aming lohika ng client-server ay nasa mga file na na-import ng file index.js. Ngayon ay isasaalang-alang natin silang lahat sa pagkakasunud-sunod.

4. Pagpapalitan ng data ng customer

Sa larong ito, gumagamit kami ng isang kilalang library para makipag-ugnayan sa server socket.io. Ang Socket.io ay may katutubong suporta Mga WebSocket, na angkop na angkop para sa two-way na komunikasyon: maaari kaming magpadala ng mga mensahe sa server ΠΈ ang server ay maaaring magpadala ng mga mensahe sa amin sa parehong koneksyon.

Magkakaroon tayo ng isang file src/client/networking.jskung sino ang mag-aalaga ng lahat komunikasyon sa 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);
};

Ang code na ito ay pinaikli din nang bahagya para sa kalinawan.

Mayroong tatlong pangunahing aksyon sa file na ito:

  • Sinusubukan naming kumonekta sa server. connectedPromise pinapayagan lamang kapag nakapagtatag na kami ng koneksyon.
  • Kung matagumpay ang koneksyon, nagrerehistro kami ng mga function ng callback (processGameUpdate() ΠΈ onGameOver()) para sa mga mensaheng matatanggap namin mula sa server.
  • Ini-export namin play() ΠΈ updateDirection()para magamit sila ng ibang mga file.

5. Pag-render ng Kliyente

Oras na para ipakita ang larawan sa screen!

…ngunit bago natin magawa iyon, kailangan nating i-download ang lahat ng mga larawan (mga mapagkukunan) na kailangan para dito. Sumulat tayo ng isang resource manager:

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

Ang pamamahala ng mapagkukunan ay hindi napakahirap ipatupad! Ang pangunahing ideya ay upang mag-imbak ng isang bagay assets, na magbubuklod sa susi ng filename sa halaga ng bagay Image. Kapag na-load ang mapagkukunan, iniimbak namin ito sa isang bagay assets para sa mabilis na pag-access sa hinaharap. Kailan papayagang mag-download ang bawat indibidwal na mapagkukunan (iyon ay, lahat mapagkukunan), pinapayagan namin downloadPromise.

Pagkatapos i-download ang mga mapagkukunan, maaari kang magsimulang mag-render. Gaya ng sinabi kanina, para gumuhit sa isang web page, ginagamit namin HTML5 Canvas (<canvas>). Ang aming laro ay medyo simple, kaya kailangan lang naming iguhit ang mga sumusunod:

  1. Background
  2. Barko ng manlalaro
  3. Iba pang mga manlalaro sa laro
  4. mga shell

Narito ang mahahalagang snippet src/client/render.js, na eksaktong nagbibigay ng apat na item na nakalista sa itaas:

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

Ang code na ito ay pinaikli din para sa kalinawan.

render() ay ang pangunahing function ng file na ito. startRendering() ΠΈ stopRendering() kontrolin ang pag-activate ng render loop sa 60 FPS.

Mga konkretong pagpapatupad ng indibidwal na pag-render ng mga function ng helper (hal. renderBullet()) ay hindi ganoon kahalaga, ngunit narito ang isang simpleng halimbawa:

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

Tandaan na ginagamit namin ang pamamaraan getAsset(), na dati nang nakita sa asset.js!

Kung interesado kang tuklasin ang iba pang mga katulong sa pag-render, basahin ang iba pa src/client/render.js.

6. Pag-input ng kliyente

Oras na para gumawa ng laro mapaglaro! Ang control scheme ay magiging napaka-simple: upang baguhin ang direksyon ng paggalaw, maaari mong gamitin ang mouse (sa isang computer) o pindutin ang screen (sa isang mobile device). Para maipatupad ito, magrerehistro kami Mga Tagapakinig ng Kaganapan para sa mga kaganapan sa Mouse at Touch.
Bahala na sa lahat ng ito 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() ay Mga Tagapakinig ng Kaganapan na tumatawag updateDirection() (mula sa networking.js) kapag naganap ang isang kaganapan sa pag-input (halimbawa, kapag ang mouse ay inilipat). updateDirection() pinangangasiwaan ang pagmemensahe sa server, na humahawak sa kaganapan ng pag-input at nag-a-update ng estado ng laro nang naaayon.

7. Estado ng Kliyente

Ang seksyong ito ang pinakamahirap sa unang bahagi ng post. Huwag panghinaan ng loob kung hindi mo ito naiintindihan sa unang pagkakataon na basahin mo ito! Maaari mo ring laktawan ito at babalikan ito mamaya.

Ang huling piraso ng puzzle na kailangan para makumpleto ang client/server code ay ay. Tandaan ang code snippet mula sa Client Rendering section?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() ay dapat makapagbigay sa amin ng kasalukuyang estado ng laro sa kliyente sa anumang punto ng oras batay sa mga update na natanggap mula sa server. Narito ang isang halimbawa ng pag-update ng laro na maaaring ipadala ng server:

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

Ang bawat pag-update ng laro ay naglalaman ng limang magkakaparehong field:

  • t: Server timestamp na nagsasaad kung kailan ginawa ang update na ito.
  • me: Impormasyon tungkol sa player na tumatanggap ng update na ito.
  • iba: Isang hanay ng impormasyon tungkol sa iba pang mga manlalaro na lumalahok sa parehong laro.
  • bullets: isang hanay ng impormasyon tungkol sa mga projectiles sa laro.
  • leaderboard: Kasalukuyang data ng leaderboard. Sa post na ito, hindi namin sila isasaalang-alang.

7.1 Walang muwang na estado ng kliyente

Walang muwang na pagpapatupad getCurrentState() maaari lamang direktang ibalik ang data ng pinakakamakailang natanggap na update sa laro.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Maganda at malinaw! Pero kung ganun lang kasimple. Isa sa mga dahilan kung bakit may problema ang pagpapatupad na ito: nililimitahan nito ang rate ng pag-render ng frame sa rate ng orasan ng server.

Frame rate: bilang ng mga frame (i.e. mga tawag render()) bawat segundo, o FPS. Karaniwang nagsusumikap ang mga laro na makamit ang hindi bababa sa 60 FPS.

Rate ng tik: Ang dalas kung saan nagpapadala ang server ng mga update sa laro sa mga kliyente. Madalas itong mas mababa kaysa sa frame rate. Sa aming laro, tumatakbo ang server sa dalas na 30 cycle bawat segundo.

Kung ire-render lang natin ang pinakabagong update ng laro, ang FPS ay talagang hindi lalampas sa 30, dahil hindi kami nakakakuha ng higit sa 30 update bawat segundo mula sa server. Kahit tumawag tayo render() 60 beses bawat segundo, pagkatapos kalahati ng mga tawag na ito ay ire-redraw lang ang parehong bagay, na talagang walang ginagawa. Ang isa pang problema sa walang muwang na pagpapatupad ay iyon madaling kapitan ng pagkaantala. Sa perpektong bilis ng Internet, makakatanggap ang kliyente ng update sa laro nang eksakto bawat 33ms (30 bawat segundo):

Paglikha ng Multiplayer .io Web Game
Sa kasamaang palad, walang perpekto. Ang isang mas makatotohanang larawan ay:
Paglikha ng Multiplayer .io Web Game
Ang walang muwang na pagpapatupad ay halos ang pinakamasamang kaso pagdating sa latency. Kung ang isang update sa laro ay natanggap na may pagkaantala ng 50ms, kung gayon mga stall ng kliyente dagdag na 50ms dahil nire-render pa rin nito ang status ng laro mula sa nakaraang update. Maaari mong isipin kung gaano ito hindi komportable para sa manlalaro: ang di-makatwirang pagpepreno ay gagawing maalog at hindi matatag ang laro.

7.2 Pinahusay na kalagayan ng kliyente

Gagawa kami ng ilang pagpapabuti sa walang muwang na pagpapatupad. Una, ginagamit namin pagkaantala sa pag-render para sa 100 ms. Nangangahulugan ito na ang "kasalukuyang" estado ng kliyente ay palaging mahuhuli sa estado ng laro sa server nang 100ms. Halimbawa, kung ang oras sa server ay 150, pagkatapos ay ire-render ng kliyente ang estado kung saan ang server ay nasa oras na iyon 50:

Paglikha ng Multiplayer .io Web Game
Nagbibigay ito sa amin ng 100ms buffer para makaligtas sa hindi inaasahang oras ng pag-update ng laro:

Paglikha ng Multiplayer .io Web Game
Magiging permanente ang kabayaran para dito input lag para sa 100 ms. Ito ay isang maliit na sakripisyo para sa maayos na paglalaro - karamihan sa mga manlalaro (lalo na sa mga kaswal na manlalaro) ay hindi mapapansin ang pagkaantala na ito. Mas madali para sa mga tao na mag-adjust sa pare-parehong 100ms latency kaysa sa paglalaro ng hindi nahuhulaang latency.

Maaari din tayong gumamit ng ibang pamamaraan na tinatawag na hula sa panig ng kliyente, na gumagawa ng magandang trabaho sa pagbabawas ng nakikitang latency, ngunit hindi sasaklawin sa post na ito.

Ang isa pang pagpapabuti na ginagamit namin ay linear interpolation. Dahil sa lag ng pag-render, kadalasan ay nauuna kami ng kahit isang update sa kasalukuyang oras sa client. Kapag tinawag getCurrentState(), maaari nating isagawa linear interpolation sa pagitan ng mga update sa laro bago at pagkatapos ng kasalukuyang oras sa client:

Paglikha ng Multiplayer .io Web Game
Malulutas nito ang isyu sa frame rate: maaari na kaming mag-render ng mga natatanging frame sa anumang frame rate na gusto namin!

7.3 Pagpapatupad ng pinahusay na estado ng kliyente

Halimbawa ng pagpapatupad sa src/client/state.js gumagamit ng parehong render lag at linear interpolation, ngunit hindi nagtagal. Hatiin natin ang code sa dalawang bahagi. Narito ang una:

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

Ang unang hakbang ay upang malaman kung ano currentServerTime(). Gaya ng nakita natin kanina, ang bawat pag-update ng laro ay may kasamang timestamp ng server. Gusto naming gumamit ng render latency para i-render ang imahe 100ms sa likod ng server, ngunit hindi namin malalaman ang kasalukuyang oras sa server, dahil hindi namin alam kung gaano katagal bago makarating sa amin ang alinman sa mga update. Ang Internet ay hindi mahuhulaan at ang bilis nito ay maaaring mag-iba nang malaki!

Para malampasan ang problemang ito, maaari tayong gumamit ng makatwirang pagtatantya: tayo kunwari dumating agad ang unang update. Kung totoo ito, malalaman natin ang oras ng server sa partikular na sandaling ito! Iniimbak namin ang timestamp ng server firstServerTimestamp at panatilihin ang aming lokal (kliyente) timestamp sa parehong sandali sa gameStart.

Ay teka. Hindi ba dapat oras ng server = oras ng kliyente? Bakit natin nakikilala ang "timestamp ng server" at " timestamp ng kliyente"? Ito ay isang mahusay na tanong! Lumalabas na hindi sila pareho. Date.now() ay magbabalik ng iba't ibang mga timestamp sa client at server, at depende ito sa mga salik na lokal sa mga makinang ito. Huwag ipagpalagay na ang mga timestamp ay magiging pareho sa lahat ng makina.

Ngayon naiintindihan namin kung ano ang ginagawa currentServerTime(): bumabalik ang timestamp ng server ng kasalukuyang oras ng pag-render. Sa madaling salita, ito ang kasalukuyang oras ng server (firstServerTimestamp <+ (Date.now() - gameStart)) bawasan ang pagkaantala sa pag-render (RENDER_DELAY).

Ngayon tingnan natin kung paano namin pinangangasiwaan ang mga update sa laro. Kapag natanggap mula sa server ng pag-update, ito ay tinatawag processGameUpdate()at ise-save namin ang bagong update sa isang array gameUpdates. Pagkatapos, upang suriin ang paggamit ng memorya, inalis namin ang lahat ng mga lumang update dati base updatedahil hindi na natin sila kailangan.

Ano ang isang "basic update"? Ito ang unang update na nakita namin sa pamamagitan ng paglipat pabalik mula sa kasalukuyang oras ng server. Tandaan ang diagram na ito?

Paglikha ng Multiplayer .io Web Game
Ang pag-update ng laro nang direkta sa kaliwa ng "Oras ng Pag-render ng Kliyente" ay ang batayang pag-update.

Ano ang ginagamit ng base update? Bakit namin maaaring i-drop ang mga update sa baseline? Upang malaman ito, hayaan natin sa wakas isaalang-alang ang pagpapatupad getCurrentState():

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

Hinahawakan namin ang tatlong kaso:

  1. base < 0 nangangahulugan na walang mga update hanggang sa kasalukuyang oras ng pag-render (tingnan ang pagpapatupad sa itaas getBaseUpdate()). Ito ay maaaring mangyari sa simula mismo ng laro dahil sa pag-render ng lag. Sa kasong ito, ginagamit namin ang pinakabagong update na natanggap.
  2. base ay ang pinakabagong update na mayroon kami. Ito ay maaaring dahil sa pagkaantala ng network o mahinang koneksyon sa Internet. Sa kasong ito, ginagamit din namin ang pinakabagong update na mayroon kami.
  3. Mayroon kaming update bago at pagkatapos ng kasalukuyang oras ng pag-render, para magawa namin interpolate!

Lahat ng natitira sa state.js ay isang pagpapatupad ng linear interpolation na simple (ngunit boring) matematika. Kung gusto mong tuklasin ito sa iyong sarili, pagkatapos ay buksan state.js sa Github.

Bahagi 2. Backend server

Sa bahaging ito, titingnan natin ang Node.js backend na kumokontrol sa aming Halimbawa ng larong .io.

1. Server Entry Point

Upang pamahalaan ang web server, gagamit kami ng sikat na web framework para sa Node.js na tinatawag Ekspres. Ito ay iko-configure ng aming server entry point file src/server/server.js:

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

Tandaan na sa unang bahagi ay tinalakay natin ang Webpack? Dito namin gagamitin ang aming mga configuration sa Webpack. Gagamitin namin ang mga ito sa dalawang paraan:

  • Gumamit webpack-dev-middleware upang awtomatikong muling buuin ang aming mga development package, o
  • static na ilipat ang folder dist/, kung saan isusulat ng Webpack ang aming mga file pagkatapos ng paggawa ng produksyon.

Isa pang mahalagang gawain server.js ay upang i-set up ang server socket.iona kumokonekta lamang sa Express server:

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

Matapos matagumpay na magtatag ng koneksyon sa socket.io sa server, nag-set up kami ng mga tagapangasiwa ng kaganapan para sa bagong socket. Pinangangasiwaan ng mga tagapangasiwa ng kaganapan ang mga mensaheng natanggap mula sa mga kliyente sa pamamagitan ng pag-delegate sa isang bagay na nag-iisa game:

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

Gumagawa kami ng .io na laro, kaya isang kopya lang ang kailangan namin Game ("Laro") - lahat ng manlalaro ay naglalaro sa parehong arena! Sa susunod na seksyon, makikita natin kung paano gumagana ang klase na ito. Game.

2. Mga server ng laro

klase Game naglalaman ng pinakamahalagang lohika sa panig ng server. Mayroon itong dalawang pangunahing gawain: pamamahala ng manlalaro ΠΈ simulation ng laro.

Magsimula tayo sa unang gawain, pamamahala ng manlalaro.

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

  // ...
}

Sa larong ito, kikilalanin natin ang mga manlalaro sa pamamagitan ng field id kanilang socket.io socket (kung nalilito ka, bumalik sa server.js). Ang Socket.io mismo ay nagtatalaga sa bawat socket ng kakaiba idkaya wala tayong dapat ikabahala tungkol diyan. tatawagan ko siya Player ID.

Sa pag-iisip na iyon, tuklasin natin ang mga variable ng instance sa isang klase Game:

  • sockets ay isang bagay na nagbubuklod sa player ID sa socket na nauugnay sa player. Nagbibigay-daan ito sa amin na ma-access ang mga socket sa pamamagitan ng kanilang mga player ID sa palagiang oras.
  • players ay isang bagay na nagbubuklod sa player ID sa code>Lagay ng manlalaro

bullets ay isang hanay ng mga bagay Bullet, na walang tiyak na pagkakasunud-sunod.
lastUpdateTime ay ang timestamp ng huling beses na na-update ang laro. Makikita natin kung paano ito gagamitin sa ilang sandali.
shouldSendUpdate ay isang auxiliary variable. Makikita rin natin ang paggamit nito sa ilang sandali.
Paraan addPlayer(), removePlayer() ΠΈ handleInput() hindi na kailangang ipaliwanag, sila ay ginagamit sa server.js. Kung kailangan mong i-refresh ang iyong memorya, bumalik nang mas mataas ng kaunti.

Huling linya constructor() nagsisimula ikot ng pag-update mga laro (na may dalas ng 60 update / s):

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

  // ...
}

pamamaraan update() naglalaman marahil ng pinakamahalagang bahagi ng server-side logic. Narito kung ano ang ginagawa nito, sa pagkakasunud-sunod:

  1. Kinakalkula kung gaano katagal dt lumipas mula noong huli update().
  2. Nire-refresh ang bawat projectile at sinisira ang mga ito kung kinakailangan. Makikita natin ang pagpapatupad ng functionality na ito mamaya. Sa ngayon, sapat na para malaman natin iyon bullet.update() nagbabalik truekung ang projectile ay dapat sirain (lumabas siya ng arena).
  3. Ina-update ang bawat manlalaro at nagpapalabas ng projectile kung kinakailangan. Makikita rin natin ang pagpapatupad na ito mamaya βˆ’ player.update() maaaring ibalik ang isang bagay Bullet.
  4. Sinusuri para sa mga banggaan sa pagitan ng mga projectiles at mga manlalaro na may applyCollisions(), na nagbabalik ng hanay ng mga projectiles na tumama sa mga manlalaro. Para sa bawat projectile na ibinalik, dinadagdagan namin ang mga puntos ng player na nagpaputok nito (gamit ang player.onDealtDamage()) at pagkatapos ay alisin ang projectile mula sa array bullets.
  5. Inaabisuhan at sinisira ang lahat ng napatay na manlalaro.
  6. Nagpapadala ng update sa laro sa lahat ng manlalaro bawat segundo mga oras kung kailan tinawag update(). Nakakatulong ito sa amin na subaybayan ang auxiliary variable na binanggit sa itaas. shouldSendUpdate. Bilang update() tinatawag na 60 beses/s, nagpapadala kami ng mga update sa laro nang 30 beses/s. kaya, dalas ng orasan Ang orasan ng server ay 30 orasan/s (napag-usapan namin ang tungkol sa mga rate ng orasan sa unang bahagi).

Bakit magpadala lamang ng mga update sa laro sa paglipas ng panahon ? Upang i-save ang channel. 30 update sa laro bawat segundo ay marami!

Bakit hindi na lang tumawag update() 30 beses bawat segundo? Upang mapabuti ang simulation ng laro. Ang mas madalas na tinatawag update(), magiging mas tumpak ang simulation ng laro. Ngunit huwag masyadong madala sa dami ng hamon. update(), dahil ito ay isang computationally mahal na gawain - 60 bawat segundo ay sapat na.

Ang natitirang klase Game binubuo ng mga pamamaraan ng katulong na ginamit sa update():

game.js part 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() medyo simple - pinag-uuri-uriin nito ang mga manlalaro ayon sa iskor, kukunin ang nangungunang limang, at ibinabalik ang username at puntos para sa bawat isa.

createUpdate() ginamit sa update() upang lumikha ng mga update sa laro na ipinamamahagi sa mga manlalaro. Ang pangunahing gawain nito ay tumawag sa mga pamamaraan serializeForUpdate()ipinatupad para sa mga klase Player ΠΈ Bullet. Tandaan na nagpapasa lang ito ng data sa bawat manlalaro tungkol sa pinakamalapit mga manlalaro at projectiles - hindi na kailangang magpadala ng impormasyon tungkol sa mga bagay ng laro na malayo sa player!

3. Mga bagay sa laro sa server

Sa aming laro, ang mga projectiles at mga manlalaro ay talagang magkatulad: sila ay abstract, bilog, movable game object. Upang samantalahin ang pagkakatulad na ito sa pagitan ng mga manlalaro at projectiles, magsimula tayo sa pamamagitan ng pagpapatupad ng base class 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,
    };
  }
}

Walang kumplikadong nangyayari dito. Ang klase na ito ay magiging isang magandang anchor point para sa extension. Tingnan natin kung paano ang klase Bullet gumagamit 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;
  }
}

Pagpapatupad Bullet napakaikli! Nagdagdag kami sa Object tanging ang mga sumusunod na extension:

  • Gamit ang isang pakete shortid para sa random na henerasyon id projectile.
  • Pagdaragdag ng field parentIDpara masubaybayan mo ang player na gumawa ng projectile na ito.
  • Pagdaragdag ng return value sa update(), na katumbas ng truekung ang projectile ay nasa labas ng arena (remember we talked about this in the last section?).

Lumipat tayo sa 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,
    };
  }
}

Ang mga manlalaro ay mas kumplikado kaysa sa mga projectiles, kaya ang ilang higit pang mga patlang ay dapat na naka-imbak sa klase na ito. Ang kanyang pamamaraan update() gumagawa ng maraming trabaho, lalo na, ibinabalik ang bagong likhang projectile kung wala nang natitira fireCooldown ( remember napag-usapan natin ito sa nakaraang section?). Pinapalawak din nito ang pamamaraan serializeForUpdate(), dahil kailangan naming magsama ng mga karagdagang field para sa player sa pag-update ng laro.

Pagkakaroon ng base class Object - isang mahalagang hakbang upang maiwasan ang paulit-ulit na code. Halimbawa, walang klase Object bawat bagay ng laro ay dapat magkaroon ng parehong pagpapatupad distanceTo(), at ang pagkopya-paste ng lahat ng mga pagpapatupad na ito sa maraming file ay magiging isang bangungot. Lalo itong nagiging mahalaga para sa malalaking proyekto.kapag ang bilang ng pagpapalawak Object lumalaki ang mga klase.

4. Pagtuklas ng banggaan

Ang tanging bagay na natitira para sa amin ay makilala kapag ang mga projectiles ay tumama sa mga manlalaro! Tandaan ang piraso ng code na ito mula sa pamamaraan update() sa klase Game:

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

    // ...
  }
}

Kailangan nating ipatupad ang pamamaraan applyCollisions(), na nagbabalik ng lahat ng projectiles na tumama sa mga manlalaro. Sa kabutihang palad, hindi ito mahirap gawin dahil

  • Ang lahat ng nagbabanggaan na bagay ay mga bilog, na siyang pinakasimpleng hugis para ipatupad ang pagtukoy ng banggaan.
  • Mayroon na tayong pamamaraan distanceTo(), na ipinatupad namin sa nakaraang seksyon sa klase Object.

Ganito ang hitsura ng aming pagpapatupad ng collision detection:

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

Ang simpleng pagtuklas ng banggaan ay batay sa katotohanang iyon dalawang bilog ang nagbanggaan kung ang distansya sa pagitan ng kanilang mga sentro ay mas mababa sa kabuuan ng kanilang radii. Narito ang kaso kung saan ang distansya sa pagitan ng mga sentro ng dalawang bilog ay eksaktong katumbas ng kabuuan ng kanilang radii:

Paglikha ng Multiplayer .io Web Game
Mayroong ilang higit pang mga aspeto upang isaalang-alang dito:

  • Ang projectile ay hindi dapat tumama sa player na lumikha nito. Ito ay maaaring makamit sa pamamagitan ng paghahambing bullet.parentID с player.id.
  • Ang projectile ay dapat lamang tumama nang isang beses sa limitadong kaso ng maraming manlalaro na nagbanggaan sa parehong oras. Malulutas namin ang problemang ito gamit ang operator break: sa sandaling matagpuan ang player na nakabangga sa projectile, ihihinto namin ang paghahanap at lumipat sa susunod na projectile.

Pagtatapos

Iyon lang! Sinakop namin ang lahat ng kailangan mong malaman upang lumikha ng isang .io web game. Anong susunod? Bumuo ng sarili mong .io na laro!

Ang lahat ng sample code ay open source at naka-post sa Github.

Pinagmulan: www.habr.com

Magdagdag ng komento