Çok Oyunculu .io Web Oyunu Oluşturma

Çok Oyunculu .io Web Oyunu Oluşturma
2015 yılında piyasaya sürüldü Agar.io yeni bir türün öncüsü oldu oyunlar .ioo zamandan beri popülaritesi arttı. .io oyunlarının popülaritesindeki artışı kişisel olarak deneyimledim: son üç yılda bu türden iki oyun yarattı ve sattı..

Bu oyunları daha önce hiç duymadıysanız, bunlar oynaması kolay, ücretsiz, çok oyunculu web oyunlarıdır (hesap gerektirmez). Genellikle aynı arenada birçok rakip oyuncuyla karşılaşırlar. Diğer ünlü .io oyunları: Slither.io и Diep.io.

Bu yazıda bunun nasıl olduğunu keşfedeceğiz sıfırdan bir .io oyunu oluşturun. Bunun için sadece Javascript bilgisi yeterli olacaktır: sözdizimi gibi şeyleri anlamanız gerekir. ES6, anahtar kelime this и Vaatler. Javascript bilginiz mükemmel olmasa bile yazının çoğunu anlayabilirsiniz.

.io oyun örneği

Öğrenme yardımı için şu adrese başvuracağız: .io oyun örneği. Oynamayı dene!

Çok Oyunculu .io Web Oyunu Oluşturma
Oyun oldukça basit; diğer oyuncuların da bulunduğu bir arenada bir gemiyi kontrol ediyorsunuz. Geminiz otomatik olarak mermi atıyor ve siz diğer oyuncuların mermilerinden kaçınarak onları vurmaya çalışıyorsunuz.

1. Projeye kısa genel bakış/yapı

tavsiye kaynak kodunu indir örnek oyun böylece beni takip edebilirsiniz.

Örnek aşağıdakileri kullanır:

  • Ekspres Oyunun web sunucusunu yöneten en popüler Node.js web çerçevesidir.
  • soket.io - tarayıcı ile sunucu arasında veri alışverişi için bir websocket kitaplığı.
  • webpack - modül yöneticisi. Webpack'in neden kullanılacağını okuyabilirsiniz. burada.

Proje dizini yapısı şöyle görünür:

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

halka açık/

Her şey bir klasörde public/ sunucu tarafından statik olarak gönderilecektir. İÇİNDE public/assets/ projemiz tarafından kullanılan görselleri içerir.

src /

Tüm kaynak kodları klasördedir src/. Türkçe client/ и server/ kendi adına konuş ve shared/ hem istemci hem de sunucu tarafından içe aktarılan bir sabitler dosyası içerir.

2. Montajlar/proje ayarları

Yukarıda da belirttiğimiz gibi projeyi inşa etmek için modül yöneticisini kullanıyoruz. webpack. Webpack yapılandırmamıza bir göz atalım:

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

Buradaki en önemli satırlar:

  • src/client/index.js Javascript (JS) istemcisinin giriş noktasıdır. Webpack buradan başlayacak ve içe aktarılan diğer dosyaları yinelemeli olarak arayacaktır.
  • Webpack derlememizin JS çıktısı dizinde bulunacaktır. dist/. Bu dosyaya bizim adını vereceğim js paketi.
  • Kullanırız Babilve özellikle konfigürasyon @babel/preset-env JS kodumuzu eski tarayıcılara aktarmak için.
  • JS dosyalarının referans verdiği tüm CSS'leri çıkarmak ve bunları tek bir yerde birleştirmek için bir eklenti kullanıyoruz. Ona bizim diyeceğim css paketi.

Garip paket dosya adlarını fark etmiş olabilirsiniz '[name].[contenthash].ext'. İçerdikleri dosya adı değişiklikleri Web paketi: [name] giriş noktasının adı ile değiştirilecektir (bizim durumumuzda bu game), Ve [contenthash] dosya içeriğinin karması ile değiştirilecektir. Bunu yapmak için yapıyoruz projeyi karma için optimize edin - tarayıcılara JS paketlerimizi süresiz olarak önbelleğe almalarını söyleyebilirsiniz, çünkü bir paket değişirse dosya adı da değişir (değişiklikler contenthash). Nihai sonuç görünüm dosyasının adı olacaktır game.dbeee76e91a97d0c7207.js.

Dosya webpack.common.js geliştirme ve tamamlanmış proje konfigürasyonlarına aktardığımız temel konfigürasyon dosyasıdır. Örnek bir geliştirme yapılandırması aşağıda verilmiştir:

webpack.dev.js

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

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

Verimlilik için geliştirme sürecinde kullanıyoruz webpack.dev.jsve şuna geçer: webpack.prod.jsÜretime dağıtırken paket boyutlarını optimize etmek için.

Yerel ayar

Bu yazıda listelenen adımları takip edebilmeniz için projeyi yerel bir makineye kurmanızı öneririm. Kurulum basittir: Öncelikle sistemin kurulu olması gerekir Düğüm и NPM. Daha sonra yapmanız gerekenler

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

ve gitmeye hazırsın! Geliştirme sunucusunu başlatmak için çalıştırmanız yeterlidir.

$ npm run develop

ve web tarayıcısına gidin localhost: 3000. Geliştirme sunucusu, kod değiştikçe JS ve CSS paketlerini otomatik olarak yeniden oluşturacaktır; tüm değişiklikleri görmek için sayfayı yenilemeniz yeterlidir!

3. Müşteri Giriş Noktaları

Oyunun kodunun kendisine geçelim. Öncelikle bir sayfaya ihtiyacımız var index.html, siteyi ziyaret ettiğinizde tarayıcı ilk önce onu yükleyecektir. Sayfamız oldukça basit olacak:

index.html

Örnek bir .io oyunu  OYNAMAK

Bu kod örneği, netlik sağlamak amacıyla biraz basitleştirildi ve diğer birçok yazı örneği için de aynısını yapacağım. Kodun tamamı her zaman şu adreste görüntülenebilir: Github.

Sahibiz:

  • HTML5 tuval öğesi (<canvas>) oyunu oluşturmak için kullanacağız.
  • <link> CSS paketimizi eklemek için.
  • <script> Javascript paketimizi eklemek için.
  • Kullanıcı adını içeren ana menü <input> ve OYNAT düğmesi (<button>).

Ana sayfayı yükledikten sonra tarayıcı, giriş noktası JS dosyasından başlayarak Javascript kodunu çalıştırmaya başlayacaktır: 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);
  };
});

Bu kulağa karmaşık gelebilir, ancak burada pek bir şey olmuyor:

  1. Diğer birkaç JS dosyasını içe aktarma.
  2. CSS içe aktarma (böylece Webpack bunları CSS paketimize dahil edeceğini bilir).
  3. Başlatmak connect() sunucuyla bağlantı kurmak ve çalıştırmak için downloadAssets() Oyunu oluşturmak için gereken görselleri indirmek için.
  4. 3. aşama tamamlandıktan sonra ana menü görüntülenir (playMenu).
  5. "OYNAT" düğmesine basmak için işleyiciyi ayarlama. Butona basıldığında kod oyunu başlatır ve sunucuya oynamaya hazır olduğumuzu bildirir.

İstemci-sunucu mantığımızın ana "eteği", dosya tarafından içe aktarılan dosyalardadır index.js. Şimdi hepsini sırayla ele alacağız.

4. Müşteri verilerinin değişimi

Bu oyunda sunucuyla iletişim kurmak için iyi bilinen bir kütüphane kullanıyoruz. soket.io. Socket.io'nun yerel desteği var WebSockets, iki yönlü iletişim için çok uygundur: sunucuya mesaj gönderebiliriz и sunucu bize aynı bağlantı üzerinden mesaj gönderebilir.

Bir dosyamız olacak src/client/networking.jskim ilgilenecek tüm sunucuyla iletişim:

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

Bu kod ayrıca netlik sağlamak amacıyla biraz kısaltılmıştır.

Bu dosyada üç ana eylem vardır:

  • Sunucuya bağlanmaya çalışıyoruz. connectedPromise yalnızca bir bağlantı kurduğumuzda izin verilir.
  • Bağlantı başarılı olursa geri arama işlevlerini kaydederiz (processGameUpdate() и onGameOver()) sunucudan alabileceğimiz mesajlar için.
  • İhracat yapıyoruz play() и updateDirection()böylece diğer dosyalar bunları kullanabilir.

5. İstemci Oluşturma

Resmi ekranda göstermenin zamanı geldi!

…ama bunu yapmadan önce bunun için gerekli olan tüm görselleri (kaynakları) indirmemiz gerekiyor. Bir kaynak yöneticisi yazalım:

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

Kaynak yönetimini uygulamak o kadar da zor değil! Ana fikir bir nesneyi saklamaktır assetsdosya adının anahtarını nesnenin değerine bağlayacak Image. Kaynak yüklendiğinde onu bir nesnede saklarız assets gelecekte hızlı erişim için. Her bir kaynağın indirilmesine ne zaman izin verilecek (yani, tüm kaynaklar), izin veriyoruz downloadPromise.

Kaynakları indirdikten sonra oluşturmaya başlayabilirsiniz. Daha önce de söylediğimiz gibi, bir web sayfasında çizim yapmak için şunu kullanırız: HTML5 Kanvas (<canvas>). Oyunumuz oldukça basit, bu yüzden sadece aşağıdakileri çizmemiz gerekiyor:

  1. Arka plân
  2. Oyuncu gemisi
  3. Oyundaki diğer oyuncular
  4. cephane

İşte önemli kesitler src/client/render.jstam olarak yukarıda listelenen dört öğeyi oluşturan:

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

Bu kod aynı zamanda netlik sağlamak amacıyla kısaltılmıştır.

render() bu dosyanın ana işlevidir. startRendering() и stopRendering() 60 FPS'de oluşturma döngüsünün etkinleştirilmesini kontrol edin.

Bireysel görüntü oluşturma yardımcı işlevlerinin somut uygulamaları (ör. renderBullet()) o kadar önemli değil ama işte basit bir örnek:

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

Yöntemi kullandığımızı unutmayın. getAsset()daha önce de görülen asset.js!

Diğer görüntü oluşturma yardımcıları hakkında bilgi edinmek istiyorsanız geri kalanını okuyun. src/client/render.js.

6. İstemci girişi

Bir oyun yapmanın zamanı geldi oynanabilir! Kontrol şeması çok basit olacaktır: Hareket yönünü değiştirmek için fareyi (bilgisayarda) kullanabilir veya ekrana (mobil cihazda) dokunabilirsiniz. Bunu uygulamak için kayıt olacağız Olay Dinleyicileri Fare ve Dokunma etkinlikleri için.
Bütün bunlarla ilgilenecek 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() arayan Olay Dinleyicileri updateDirection() (dan networking.js) bir giriş olayı meydana geldiğinde (örneğin, fare hareket ettirildiğinde). updateDirection() giriş olayını işleyen ve oyun durumunu buna göre güncelleyen sunucuyla mesajlaşmayı yönetir.

7. Müşteri Durumu

Bu bölüm yazının ilk bölümünde en zor olanıdır. İlk okuduğunuzda anlamazsanız cesaretiniz kırılmasın! Hatta atlayıp daha sonra geri dönebilirsiniz.

İstemci/sunucu kodunu tamamlamak için gereken bulmacanın son parçası belirtmek, bildirmek. İstemci Oluşturma bölümündeki kod pasajını hatırlıyor musunuz?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() istemcide oyunun mevcut durumunu bize verebilmelidir herhangi bir zamanda sunucudan alınan güncellemelere dayanmaktadır. Sunucunun gönderebileceği oyun güncellemesinin bir örneği:

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

Her oyun güncellemesi beş özdeş alan içerir:

  • t: Bu güncellemenin ne zaman oluşturulduğunu gösteren sunucu zaman damgası.
  • me: Bu güncellemeyi alan oynatıcı hakkında bilgi.
  • diğerleri: Aynı oyuna katılan diğer oyuncular hakkında bir dizi bilgi.
  • mermi: Oyundaki mermiler hakkında bir dizi bilgi.
  • Liderler Sıralaması: Mevcut skor tablosu verileri. Bu yazımızda bunları ele almayacağız.

7.1 Saf istemci durumu

Saf uygulama getCurrentState() yalnızca en son alınan oyun güncellemesinin verilerini doğrudan döndürebilir.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Güzel ve net! Ama keşke bu kadar basit olsaydı. Bu uygulamanın sorunlu olmasının nedenlerinden biri: oluşturma kare hızını sunucu saat hızıyla sınırlar.

Kare hızı: çerçeve sayısı (yani çağrılar render()) saniyede veya FPS. Oyunlar genellikle en az 60 FPS elde etmeye çalışır.

Onay Oranı: Sunucunun istemcilere oyun güncellemelerini gönderme sıklığı. Genellikle kare hızından daha düşüktür. Oyunumuzda sunucu saniyede 30 döngü frekansında çalışmaktadır.

Oyunun en son güncellemesini yaparsak FPS aslında hiçbir zaman 30'un üzerine çıkmaz çünkü sunucudan hiçbir zaman saniyede 30'dan fazla güncelleme alamıyoruz. Arasak bile render() Saniyede 60 kez, bu çağrıların yarısı aynı şeyi yeniden çizecek, aslında hiçbir şey yapmayacak. Saf uygulamayla ilgili bir başka sorun da, gecikmelere eğilimli. İdeal İnternet hızıyla, istemci tam olarak her 33 ms'de bir (saniyede 30 ms) bir oyun güncellemesi alacaktır:

Çok Oyunculu .io Web Oyunu Oluşturma
Ne yazık ki hiçbir şey mükemmel değildir. Daha gerçekçi bir resim şöyle olurdu:
Çok Oyunculu .io Web Oyunu Oluşturma
Gecikme söz konusu olduğunda saf uygulama neredeyse en kötü durumdur. Bir oyun güncellemesi 50 ms gecikmeyle alınırsa, o zaman müşteri tezgahları fazladan 50 ms çünkü hâlâ önceki güncellemedeki oyun durumunu gösteriyor. Bunun oyuncu için ne kadar rahatsız edici olduğunu tahmin edebilirsiniz: Keyfi frenleme, oyunun sarsıntılı ve dengesiz olmasına neden olur.

7.2 Geliştirilmiş istemci durumu

Naif uygulamada bazı iyileştirmeler yapacağız. Öncelikle kullanıyoruz oluşturma gecikmesi 100 ms için. Bu, istemcinin "mevcut" durumunun her zaman sunucudaki oyunun durumunun 100 ms gerisinde kalacağı anlamına gelir. Örneğin, sunucudaki saat 150, daha sonra istemci, sunucunun o sırada bulunduğu durumu işleyecektir. 50:

Çok Oyunculu .io Web Oyunu Oluşturma
Bu bize öngörülemeyen oyun güncelleme sürelerine dayanabilmemiz için 100 ms'lik bir arabellek sağlar:

Çok Oyunculu .io Web Oyunu Oluşturma
Bunun karşılığı kalıcı olacak giriş gecikmesi 100 ms için. Bu, sorunsuz bir oyun deneyimi için küçük bir fedakarlıktır; çoğu oyuncu (özellikle sıradan oyuncular) bu gecikmeyi fark etmeyecektir bile. İnsanların sabit 100 ms gecikmeye alışması, öngörülemeyen bir gecikmeyle oynamaktan çok daha kolaydır.

Ayrıca başka bir teknik de kullanabiliriz. istemci tarafı tahminiBu, algılanan gecikmeyi azaltma konusunda iyi bir iş çıkarıyor ancak bu yazıda ele alınmayacak.

Kullandığımız bir diğer gelişme ise doğrusal enterpolasyon. Oluşturma gecikmesi nedeniyle genellikle istemcideki geçerli saatten en az bir güncelleme öndeyiz. Arandığında getCurrentState(), yürütebiliriz doğrusal enterpolasyon oyun güncellemeleri arasında istemcideki geçerli saatten hemen önce ve sonra:

Çok Oyunculu .io Web Oyunu Oluşturma
Bu, kare hızı sorununu çözüyor: Artık benzersiz kareleri istediğimiz herhangi bir kare hızında oluşturabiliyoruz!

7.3 Gelişmiş istemci durumunun uygulanması

Uygulama örneği src/client/state.js hem oluşturma gecikmesini hem de doğrusal enterpolasyonu kullanır, ancak uzun sürmez. Kodu iki parçaya ayıralım. İşte birincisi:

state.js bölüm 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;
}

İlk adım ne olduğunu anlamaktır currentServerTime(). Daha önce de gördüğümüz gibi her oyun güncellemesi bir sunucu zaman damgası içerir. Görüntüyü sunucunun 100 ms arkasında oluşturmak için oluşturma gecikmesini kullanmak istiyoruz, ancak sunucudaki şu anki saati asla bilemeyeceğiz, çünkü güncellemelerin bize ulaşmasının ne kadar sürdüğünü bilmiyoruz. İnternet tahmin edilemez ve hızı büyük ölçüde değişebilir!

Bu sorunu aşmak için makul bir yaklaşım kullanabiliriz: ilk güncelleme anında gelmiş gibi davranın. Eğer bu doğru olsaydı, o zaman sunucunun şu anki saatini biliyor olurduk! Sunucunun zaman damgasını saklıyoruz firstServerTimestamp ve bizi koru yerel (istemci) zaman damgası aynı anda gameStart.

Bekle. Sunucu zamanı = istemci zamanı olması gerekmez mi? Neden "sunucu zaman damgası" ile "istemci zaman damgası" arasında ayrım yapıyoruz? Bu harika bir soru! Bunların aynı şey olmadığı ortaya çıktı. Date.now() istemci ve sunucuda farklı zaman damgaları döndürecektir ve bu, bu makinelerdeki yerel faktörlere bağlıdır. Zaman damgalarının tüm makinelerde aynı olacağını asla varsaymayın.

Artık ne işe yaradığını anlıyoruz currentServerTime(): geri döner geçerli oluşturma zamanının sunucu zaman damgası. Başka bir deyişle, bu sunucunun geçerli saatidir (firstServerTimestamp <+ (Date.now() - gameStart)) eksi oluşturma gecikmesi (RENDER_DELAY).

Şimdi oyun güncellemelerini nasıl ele aldığımıza bir göz atalım. Güncelleme sunucusundan alındığında çağrılır processGameUpdate()ve yeni güncellemeyi bir diziye kaydediyoruz gameUpdates. Daha sonra hafıza kullanımını kontrol etmek için tüm eski güncellemeleri kaldırıyoruz. temel güncellemeçünkü artık onlara ihtiyacımız yok.

"Temel güncelleme" nedir? Bu sunucunun geçerli saatinden geriye doğru giderek bulduğumuz ilk güncelleme. Bu diyagramı hatırladınız mı?

Çok Oyunculu .io Web Oyunu Oluşturma
"İstemci Oluşturma Süresi"nin hemen solundaki oyun güncellemesi temel güncellemedir.

Temel güncelleme ne için kullanılır? Güncellemeleri neden taban çizgisine bırakabiliyoruz? Bunu anlamak için hadi наконец-то uygulamayı göz önünde bulundurun getCurrentState():

state.js bölüm 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),
    };
  }
}

Üç vakayı ele alıyoruz:

  1. base < 0 geçerli oluşturma zamanına kadar hiçbir güncelleme olmadığı anlamına gelir (yukarıdaki uygulamaya bakın) getBaseUpdate()). Bu, oluşturma gecikmesi nedeniyle oyunun hemen başında gerçekleşebilir. Bu durumda alınan en son güncellemeyi kullanırız.
  2. base sahip olduğumuz en son güncellemedir. Bunun nedeni ağ gecikmesi veya zayıf İnternet bağlantısı olabilir. Bu durumda elimizdeki en son güncellemeyi de kullanıyoruz.
  3. Mevcut oluşturma süresinin hem öncesinde hem de sonrasında bir güncellememiz var, böylece şunları yapabiliriz: enterpolasyon!

Geriye kalan tek şey state.js basit (ama sıkıcı) bir matematik olan doğrusal enterpolasyonun bir uygulamasıdır. Kendiniz keşfetmek istiyorsanız açın state.js üzerinde Github.

Bölüm 2. Arka uç sunucusu

Bu bölümde, kontrollerimizi kontrol eden Node.js arka ucuna göz atacağız. .io oyun örneği.

1. Sunucu Giriş Noktası

Web sunucusunu yönetmek için Node.js adlı popüler bir web çerçevesi kullanacağız. Ekspres. Sunucu giriş noktası dosyamız tarafından yapılandırılacaktır. src/server/server.js:

sunucu.js bölüm 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}`);

İlk bölümde Webpack'i tartıştığımızı hatırlıyor musunuz? Webpack yapılandırmalarımızı kullanacağımız yer burasıdır. Bunları iki şekilde kullanacağız:

  • Kullanmak webpack-dev-middleware geliştirme paketlerimizi otomatik olarak yeniden oluşturmak için veya
  • klasörü statik olarak aktar dist/Webpack'in üretim derlemesinden sonra dosyalarımızı yazacağı.

Bir diğer önemli görev server.js sunucuyu kurmaktır soket.iosadece Express sunucusuna bağlanan:

sunucu.js bölüm 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);
});

Sunucuyla başarılı bir şekilde Socket.io bağlantısını kurduktan sonra yeni soket için olay işleyicilerini ayarlıyoruz. Olay işleyicileri, istemcilerden alınan mesajları tek bir nesneye yetki vererek yönetir game:

sunucu.js bölüm 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);
}

Bir .io oyunu oluşturuyoruz, bu yüzden yalnızca bir kopyaya ihtiyacımız var Game ("Oyun") - tüm oyuncular aynı arenada oynar! Bir sonraki bölümde bu sınıfın nasıl çalıştığını göreceğiz. Game.

2. Oyun sunucuları

sınıf Game sunucu tarafındaki en önemli mantığı içerir. İki ana görevi vardır: oyuncu yönetimi и oyun simülasyonu.

İlk görev olan oyuncu yönetimiyle başlayalım.

game.js bölüm 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);
    }
  }

  // ...
}

Bu oyunda oyuncuları sahaya göre belirleyeceğiz id onların soket.io soketi (kafanız karışırsa geri dönün server.js). Socket.io'nun kendisi her sokete benzersiz bir değer atar. idyani bu konuda endişelenmemize gerek yok. onu arayacağım Oyuncu Kimliği.

Bunu aklımızda tutarak, bir sınıftaki örnek değişkenleri inceleyelim Game:

  • sockets oynatıcı kimliğini oynatıcıyla ilişkili yuvaya bağlayan bir nesnedir. Soketlere oyuncu kimlikleriyle sabit bir sürede erişmemizi sağlar.
  • players oyuncu kimliğini kod>Oyuncu nesnesine bağlayan bir nesnedir

bullets bir nesne dizisidir Bullet, kesin bir sırası yoktur.
lastUpdateTime Oyunun en son güncellendiği zamanın zaman damgasıdır. Birazdan nasıl kullanıldığını göreceğiz.
shouldSendUpdate yardımcı değişkendir. Kısa süre içerisinde kullanımını da göreceğiz.
Yöntemler addPlayer(), removePlayer() и handleInput() Açıklamaya gerek yok, kullanılıyorlar server.js. Hafızanızı yenilemeniz gerekiyorsa biraz daha yukarılara gidin.

Son satır constructor() lansmanlar güncelleme döngüsü oyunlar (60 güncelleme / sn sıklığıyla):

game.js bölüm 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;
    }
  }

  // ...
}

yöntem update() belki de sunucu tarafı mantığının en önemli parçasını içerir. Sırasıyla yaptığı şey şu:

  1. Ne kadar süreceğini hesaplar dt sonuncusundan beri geçti update().
  2. Her mermiyi yeniler ve gerekirse onları yok eder. Bu işlevin uygulanmasını daha sonra göreceğiz. Şimdilik bunu bilmemiz yeterli bullet.update() döner truemerminin imha edilmesi gerekiyorsa (arenadan çıktı).
  3. Her oyuncuyu günceller ve gerekirse bir mermi üretir. Bu uygulamayı daha sonra da göreceğiz - player.update() bir nesneyi döndürebilir Bullet.
  4. Mermiler ve oyuncular arasındaki çarpışmaları kontrol eder applyCollisions(), oyunculara çarpan bir dizi mermiyi döndürür. Geri dönen her mermi için, onu ateşleyen oyuncunun puanlarını artırıyoruz (kullanarak player.onDealtDamage()) ve ardından mermiyi diziden çıkarın bullets.
  5. Öldürülen tüm oyuncuları bilgilendirir ve yok eder.
  6. Tüm oyunculara oyun güncellemesi gönderir her saniye çağrıldığı zamanlar update(). Bu, yukarıda bahsedilen yardımcı değişkeni takip etmemize yardımcı olur. shouldSendUpdate. Gibi update() 60 kez/sn çağrıldığında, oyun güncellemelerini 30 kez/sn gönderiyoruz. Böylece, saat frekansı sunucu saati 30 saat/s'dir (ilk bölümde saat hızlarından bahsetmiştik).

Neden yalnızca oyun güncellemeleri gönderilsin? zamanla ? Kanalı kaydetmek için. Saniyede 30 oyun güncellemesi çok fazla!

Neden sadece aramıyorsunuz? update() Saniyede 30 kez mi? Oyun simülasyonunu geliştirmek için. Daha sık çağrılan update()oyun simülasyonu o kadar doğru olur. Ancak zorlukların sayısına kendinizi fazla kaptırmayın. update(), çünkü bu hesaplama açısından pahalı bir görevdir - saniyede 60 yeterlidir.

Sınıfın geri kalanı Game kullanılan yardımcı yöntemlerden oluşur. update():

game.js bölüm 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() Oldukça basit; oyuncuları puana göre sıralıyor, ilk beşi alıyor ve her birinin kullanıcı adını ve puanını döndürüyor.

createUpdate() kullanılan update() oyunculara dağıtılacak oyun güncellemeleri oluşturmak. Ana görevi yöntemleri çağırmak serializeForUpdate()sınıflar için uygulanan Player и Bullet. Yalnızca her oyuncuya yaklaşık olarak veri ilettiğini unutmayın. en yakın oyuncular ve mermiler - oyuncudan uzaktaki oyun nesneleri hakkında bilgi aktarmaya gerek yok!

3. Sunucudaki oyun nesneleri

Oyunumuzda mermiler ve oyuncular aslında çok benzer: soyut, yuvarlak, hareketli oyun nesneleridir. Oyuncular ve mermiler arasındaki bu benzerlikten yararlanmak için temel sınıfı uygulayarak başlayalım Object:

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

Burada karmaşık hiçbir şey olmuyor. Bu sınıf, uzantı için iyi bir bağlantı noktası olacaktır. Bakalım sınıf nasıl Bullet использует 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;
  }
}

uygulama Bullet çok kısa! Şuraya ekledik: Object yalnızca aşağıdaki uzantılar:

  • Paket kullanma kısa numara rastgele nesil için id mermi.
  • Alan ekleme parentIDböylece bu mermiyi yaratan oyuncuyu takip edebilirsiniz.
  • Bir dönüş değeri ekleme update(), şuna eşittir: truemermi arenanın dışındaysa (geçen bölümde bunun hakkında konuştuğumuzu hatırlıyor musunuz?).

Konusuna geçelim Player:

oynatıcı.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,
    };
  }
}

Oyuncular mermilerden daha karmaşıktır, dolayısıyla bu sınıfta birkaç alanın daha saklanması gerekir. Onun yöntemi update() çok iş yapıyor, özellikle yeni oluşturulan mermiyi eğer kalmadıysa geri veriyor fireCooldown (bu konuyu önceki bölümde konuştuğumuzu hatırlıyor musunuz?) Ayrıca yöntemi genişletir serializeForUpdate()çünkü oyun güncellemesinde oyuncu için ek alanlar eklememiz gerekiyor.

Temel sınıfa sahip olmak Object - kodun tekrarlanmasını önlemek için önemli bir adım. Mesela sınıf yok Object her oyun nesnesi aynı uygulamaya sahip olmalıdır distanceTo()ve tüm bu uygulamaları birden fazla dosyaya kopyalayıp yapıştırmak bir kabus olurdu. Bu özellikle büyük projeler için önem kazanmaktadır.sayı genişlediğinde Object sınıflar artıyor.

4. Çarpışma tespiti

Bize kalan tek şey, mermilerin oyunculara ne zaman çarptığını anlamak! Bu kod parçasını yöntemden hatırla update() sınıfta Game:

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

    // ...
  }
}

Yöntemi uygulamamız gerekiyor. applyCollisions(), oyunculara çarpan tüm mermileri döndürür. Neyse ki bunu yapmak o kadar da zor değil çünkü

  • Çarpışan tüm nesneler, çarpışma tespitini uygulamak için en basit şekil olan dairelerdir.
  • Zaten bir yöntemimiz var distanceTo()sınıfta önceki bölümde uyguladığımız Object.

Çarpışma algılama uygulamamız şu şekilde görünüyor:

çarpışmalar.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;
}

Bu basit çarpışma tespiti şu gerçeğe dayanmaktadır: Merkezleri arasındaki mesafe yarıçaplarının toplamından küçükse iki daire çarpışır. İki dairenin merkezleri arasındaki mesafenin tam olarak yarıçaplarının toplamına eşit olduğu durum:

Çok Oyunculu .io Web Oyunu Oluşturma
Burada dikkate alınması gereken birkaç husus daha var:

  • Mermi onu oluşturan oyuncuya çarpmamalıdır. Bu, karşılaştırarak elde edilebilir bullet.parentID с player.id.
  • Birden fazla oyuncunun aynı anda çarpışması durumunda merminin yalnızca bir kez vurması gerekir. Bu sorunu operatörü kullanarak çözeceğiz break: Mermiye çarpan oyuncu bulunur bulunmaz aramayı durdurup bir sonraki mermiye geçiyoruz.

Son

Bu kadar! Bir .io web oyunu oluşturmak için bilmeniz gereken her şeyi ele aldık. Sıradaki ne? Kendi .io oyununuzu oluşturun!

Tüm örnek kodlar açık kaynaktır ve şu adreste yayınlanmıştır: Github.

Kaynak: habr.com

Yorum ekle