首頁  >  文章  >  web前端  >  透過範例在 Unity 和 NodeJS 上的遊戲中創建安全、快速的多人遊戲

透過範例在 Unity 和 NodeJS 上的遊戲中創建安全、快速的多人遊戲

WBOY
WBOY原創
2024-09-01 21:02:321044瀏覽

介紹

規劃多人遊戲開發方法 - 在整個專案的進一步開發中發揮著最重要的作用之一,因為它包含了我們在創建真正高品質的產品時應該考慮的許多標準。在今天的宣言教程中,我們將看一個方法範例,該方法使我們能夠創建真正快速的遊戲,同時尊重所有安全和反違規規則。

Creating safe and fast multiplayer in games on Unity and NodeJS with examples

所以,讓我們定義我們的主要標準:

  1. 多人遊戲需要一種特殊的方法來管理網路同步,尤其是在即時情況下。 二進位協定用於加速客戶端之間的資料同步,反應欄位將有助於以最小的延遲和節省記憶體來更新玩家位置。
  2. 伺服器權限是一項重要原則,關鍵資料僅在伺服器上處理,確保遊戲完整性並防止作弊。然而,為了讓我們最大限度地提高效能 - 伺服器只進行關鍵更新,剩下的交給客戶端反作弊
  3. 實施客戶端防欺詐,以便在伺服器上不增加負載的情況下處理不太關鍵的資料

Creating safe and fast multiplayer in games on Unity and NodeJS with examples

架構的主要組成部分

  1. 客戶端(Unity):客戶端負責顯示遊戲狀態,將玩家操作傳送到伺服器並從伺服器接收更新。這裡也使用反應欄位來動態更新玩家位置。
  2. 伺服器端(Node.js):伺服器處理關鍵資料(例如,移動、碰撞和玩家動作)並將更新傳送到所有連接的客戶端。非關鍵資料可以在客戶端處理並使用伺服器轉發到其他客戶端。
  3. 二進位協定:二進位資料序列化用於減少傳輸的資料量並提高效能。
  4. 同步: 提供客戶端之間的資料快速同步,以最大程度地減少延遲並確保流暢的遊戲體驗。
  5. 客戶端反作弊:它用於我們可以在客戶端上更改並發送給其他客戶端的資料。

第 1 步:在 Node.js 中實作伺服器

首先,您需要在 Node.js 上設定一個伺服器。伺服器將負責所有關鍵計算並將更新的資料傳輸給玩家。

安裝環境
若要在 Node.js 上建立伺服器,請安裝必要的依賴項:

mkdir multiplayer-game-server
cd multiplayer-game-server
npm init -y
npm install socket.io

Socket.io可以輕鬆地使用Web套接字實現客戶端和伺服器之間的即時雙向通訊。

基本伺服器實作
讓我們建立一個簡單的伺服器,它將處理客戶端連接、檢索資料、計算關鍵狀態並在所有客戶端之間同步它們。

// Create a simple socket IO server
const io = require('socket.io')(3000, {
    cors: {
        origin: '*'
    }
});

// Simple example of game states
let gameState = {};
let playerSpeedConfig = {
    maxX: 1,
    maxY: 1,
    maxZ: 1
};

// Work with new connection
io.on('connection', (socket) => {
    console.log('Player connected:', socket.id);

    // Initialize player state for socket ID
    gameState[socket.id] = { x: 0, y: 0, z: 0 };

    // work with simple player command for movement
    socket.on('playerMove', (data) => {
        const { id, dx, dy, dz } = parsePlayerMove(data);

        // Check Maximal Values
        if(dx > playerSpeedConfig.maxX) dx = playerSpeedConfig.maxX;
        if(dy > playerSpeedConfig.maxY) dx = playerSpeedConfig.maxY;
        if(dz > playerSpeedConfig.maxZ) dx = playerSpeedConfig.maxZ;

        // update game state for current player
        gameState[id].x += dx;
        gameState[id].y += dy;
        gameState[id].z += dz;

        // Send new state for all clients
        const updatedData = serializeGameState(gameState);
        io.emit('gameStateUpdate', updatedData);
    });

    // Work with unsafe data
    socket.on('dataupdate', (data) => {
        const { id, unsafe } = parsePlayerUnsafe(data);

        // update game state for current player
        gameState[id].unsafeValue += unsafe;

        // Send new state for all clients
        const updatedData = serializeGameState(gameState);
        io.emit('gameStateUpdate', updatedData);
    });

    // Work with player disconnection
    socket.on('disconnect', () => {
        console.log('Player disconnected:', socket.id);
        delete gameState[socket.id];
    });
});

// Simple Parse our binary data
function parsePlayerMove(buffer) {
    const id = buffer.toString('utf8', 0, 16); // Player ID (16 bit)
    const dx = buffer.readFloatLE(16);         // Delta X
    const dy = buffer.readFloatLE(20);         // Delta  Y
    const dz = buffer.readFloatLE(24);         // Delta  Z
    return { id, dx, dy, dz };
}

// Simple Parse of unsafe data
function parsePlayerUnsafe(buffer) {
    const id = buffer.toString('utf8', 0, 16); // Player ID (16 bit)
    const unsafe = buffer.readFloatLE(16);     // Unsafe float
    return { id, unsafe };
}

// Simple game state serialization for binary protocol
function serializeGameState(gameState) {
    const buffers = [];
    for (const [id, data] of Object.entries(gameState)) {
        // Player ID
        const idBuffer = Buffer.from(id, 'utf8');

        // Position (critical) Buffer
        const posBuffer = Buffer.alloc(12);
        posBuffer.writeFloatLE(data.x, 0);
        posBuffer.writeFloatLE(data.y, 4);
        posBuffer.writeFloatLE(data.z, 8);

        // Unsafe Data Buffer
        const unsafeBuffer = Buffer.alloc(4);
        unsafeBuffer.writeFloatLE(data.unsafeValue, 0);

        // Join all buffers
        buffers.push(Buffer.concat([idBuffer, posBuffer, unsafeBuffer]));
    }
    return Buffer.concat(buffers);
}

此伺服器執行以下操作:

  1. 處理客戶端連線。
  2. 接收二進位格式的玩家移動數據,驗證它,更新伺服器上的狀態並將其傳送到所有客戶端。
  3. 以最小延遲同步遊戲狀態,使用二進位格式來減少資料量。
  4. 簡單地轉發來自客戶端的不安全資料。

重點:

  1. 伺服器權限:所有重要資料均在伺服器上處理和儲存。客戶端僅發送操作命令(例如,位置變化增量)。
  2. 二進位資料傳輸:使用二進位協定可以節省流量並提高網路效能,特別是對於頻繁的即時資料交換。

步驟2:在Unity上實現客戶端部分

現在讓我們在 Unity 上建立一個與伺服器互動的客戶端部分。

要將 Unity 連接到 Socket.IO 上的伺服器,您需要連接專為 Unity 設計的程式庫。
在這種情況下,我們不受任何特定實作的約束(事實上它們都是相似的),而只是使用一個抽象範例。

Using reactive fields for synchronization
We will use reactive fields to update player positions. This will allow us to update states without having to check the data in each frame via the Update() method. Reactive fields automatically update the visual representation of objects in the game when the state of the data changes.

To get a reactive properties functional you can use UniRx.

Client code on Unity
Let's create a script that will connect to the server, send data and receive updates via reactive fields.

using UnityEngine;
using SocketIOClient;
using UniRx;
using System;
using System.Text;

// Basic Game Client Implementation
public class GameClient : MonoBehaviour
{
    // SocketIO Based Client
    private SocketIO client;

    // Our Player Reactive Position
    public ReactiveProperty<Vector3> playerPosition = new ReactiveProperty<Vector3>(Vector3.zero);

    // Client Initialization
    private void Start()
    {
        // Connect to our server
        client = new SocketIO("http://localhost:3000");

        // Add Client Events
        client.OnConnected += OnConnected;    // On Connected
        client.On("gameStateUpdate", OnGameStateUpdate); // On Game State Changed

        // Connect to Socket Async
        client.ConnectAsync();

        // Subscribe to our player position changed
        playerPosition.Subscribe(newPosition => {
            // Here you can interpolate your position instead
            // to get smooth movement at large ping
            transform.position = newPosition;
        });

        // Add Movement Commands
        Observable.EveryUpdate().Where(_ => Input.GetKey(KeyCode.W)).Subscribe(_ => ProcessInput(true));
        Observable.EveryUpdate().Where(_ => Input.GetKey(KeyCode.S)).Subscribe(_ => ProcessInput(false));
    }

    // On Player Connected
    private async void OnConnected(object sender, EventArgs e)
    {
        Debug.Log("Connected to server!");
    }

    // On Game State Update
    private void OnGameStateUpdate(SocketIOResponse response)
    {
        // Get our binary data
        byte[] data = response.GetValue<byte[]>();

        // Work with binary data
        int offset = 0;
        while (offset < data.Length)
        {
            // Get Player ID
            string playerId = Encoding.UTF8.GetString(data, offset, 16);
            offset += 16;

            // Get Player Position
            float x = BitConverter.ToSingle(data, offset);
            float y = BitConverter.ToSingle(data, offset + 4);
            float z = BitConverter.ToSingle(data, offset + 8);
            offset += 12;

            // Get Player unsafe variable
            float unsafeVariable = BitConverter.ToSingle(data, offset);

            // Check if it's our player position
            if (playerId == client.Id)
                playerPosition.Value = new Vector3(x, y, z);
            else
                UpdateOtherPlayerPosition(playerId, new Vector3(x, y, z), unsafeVariable);
        }
    }

    // Process player input
    private void ProcessInput(bool isForward){
        if (isForward)
            SendMoveData(new Vector3(0, 0, 1)); // Move Forward
        else
            SendMoveData(new Vector3(0, 0, -1)); // Move Backward
    }

    // Send Movement Data
    private async void SendMoveData(Vector3 delta)
    {
        byte[] data = new byte[28];
        Encoding.UTF8.GetBytes(client.Id).CopyTo(data, 0);
        BitConverter.GetBytes(delta.x).CopyTo(data, 16);
        BitConverter.GetBytes(delta.y).CopyTo(data, 20);
        BitConverter.GetBytes(delta.z).CopyTo(data, 24);

        await client.EmitAsync("playerMove", data);
    }

    // Send any unsafe data
    private async void SendUnsafeData(float unsafeData){
        byte[] data = new byte[20];
        Encoding.UTF8.GetBytes(client.Id).CopyTo(data, 0);
        BitConverter.GetBytes(unsafeData).CopyTo(data, 16);
        await client.EmitAsync("dataUpdate", data);
    }

    // Update Other players position
    private void UpdateOtherPlayerPosition(string playerId, Vector3 newPosition, float unsafeVariable)
    {
        // Here we can update other player positions and variables
    }

    // On Client Object Destroyed
    private void OnDestroy()
    {
        client.DisconnectAsync();
    }
}

Step 3: Optimize synchronization and performance

To ensure smooth gameplay and minimize latency during synchronization, it is recommended:

  1. Use interpolation: Clients can use interpolation to smooth out movements between updates from the server. This compensates for small network delays.
  2. Batch data sending: Instead of sending data on a per-move basis, use batch sending. For example, send updates every few milliseconds, which will reduce network load.
  3. Reduce the frequency of updates: Reduce the frequency of sending data to a reasonable minimum. For example, updating 20-30 times per second may be sufficient for most games.

How to simplify working with the binary protocol?

In order to simplify your work with a binary protocol - create a basic principle of data processing, as well as schemes of interaction with it.

For our example, we can take a basic protocol where:
1) The first 4 bits are the maxa of the request the user is making (e.g. 0 - move player, 1 - shoot, etc.);
2) The next 16 bits are the ID of our client.
3) Next we fill in the data that is passed through the loop (some Net Variables), where we store the ID of the variable, the size of the offset in bytes to the beginning of the next variable, the type of the variable and its value.

For the convenience of version and data control - we can create a client-server communication schema in a convenient format (JSON / XML) and download it once from the server to further parse our binary data according to this schema for the required version of our API.

Client Anti-Cheat

It doesn't make sense to process every data on the server, some of them are easier to modify on the client side and just send to other clients.

To make you a bit more secure in this scheme - you can use client-side anti-chit system to prevent memory hacks - for example, my GameShield - a free open source solution.

Conclusion

We took a simple example of developing a multiplayer game on Unity with a Node.js server, where all critical data is handled on the server to ensure the integrity of the game. Using a binary protocol to transfer data helps optimize traffic, and reactive programming in Unity makes it easy to synchronize client state without having to use the Update() method.

This approach not only improves game performance, but also increases protection against cheating by ensuring that all key calculations are performed on the server rather than the client.

And of course, as always thank you for reading the article. If you still have any questions or need help in organizing your architecture for multiplayer project - I invite you to my Discord


You can also help me out a lot in my plight and support the release of new articles and free for everyone libraries and assets for developers:

My Discord | My Blog | My GitHub

BTC: bc1qef2d34r4xkrm48zknjdjt7c0ea92ay9m2a7q55

ETH: 0x1112a2Ef850711DF4dE9c432376F255f416ef5d0
USDT (TRC20): TRf7SLi6trtNAU6K3pvVY61bzQkhxDcRLC

以上是透過範例在 Unity 和 NodeJS 上的遊戲中創建安全、快速的多人遊戲的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn