Создание игр в реальном времени с использованием Workers, Durable Objects и Unity

Durable Objects - отличное дополнение к экосистеме разработчиков Workers, позволяющее вам обращаться к конкретному Worker и работать внутри него для обеспечения согласованности в ваших приложениях. Это звучит захватывающе на высоком уровне, но если вы похожи на меня, вы можете спросить: «Хорошо, а что я могу построить из этого?»


Нет ничего лучше, чем создать что-то реальное с помощью технологии, чтобы по-настоящему понять это.

Чтобы лучше понять, почему Durable Objects имеют значение и как новые объявления в экосистеме Workers, такие как WebSockets , взаимодействуют с Durable Objects, я обратился к категории программного обеспечения, которое я создавал в свободное время уже несколько месяцев: видеоиграм.

Технические аспекты игр сильно изменились за последнее десятилетие. Многие игры по умолчанию подключены к сети, а повсеместное распространение таких инструментов, как Unity , сделало это так, что каждый может начать экспериментировать с разработкой игр.

Я много слышал о способности Durable Objects и WebSockets обеспечивать согласованность приложений в реальном времени, и для проверки этого варианта использования я создал Durable World : простой многопользовательский трехмерный мир, который полностью развернут на нашем Cloudflare. stack: страницы для обслуживания клиентской игры, которая работает в Unity и WebGL, и Workers в качестве координационного уровня, использующие Durable Objects и WebSockets для синхронизации положения игрока и другой информации, например, случайно сгенерированных имен пользователей.


3D-игры, как правило, выглядят действительно впечатляюще - они служат отличными техническими демонстрациями. Даже с учетом того, что люди со всего мира соединяются и перемещаются вместе с вами по карте, вы, вероятно, удивитесь, насколько прост соответствующий код для этого проекта. Давайте погрузимся в клиентский и серверный аспекты Durable World, а затем я выскажу некоторые мысли о том, как подобный проект может развиваться в будущем, и что я хотел бы изучить дальше.

Отдельно от этого сообщения в блоге мы также недавно опубликовали сообщение в блоге Cloudflare, показывающее многопользовательский порт Doom на Workers, использующих WebAssembly и Durable Objects. Количество вариантов использования для игр на Workers очень велико с добавлением таких инструментов, как Durable Objects, WebSockets и WebAssembly, независимо от того, переносите ли вы существующие игры или создаете совершенно новые.

Durable World построен с использованием авторитетной клиентской модели. Клиент запускает скомпилированную игру прямо в браузере, встроенном в WebAssembly, поэтому ее можно запускать без необходимости загружать клиент, зависящий от платформы, на локальный компьютер. Сервер, который полностью работает на Cloudflare Workers, может взаимодействовать с ним через WebSockets и использует устойчивые объекты для управления состоянием игры.

Подобно примеру Doom, который мы продемонстрировали в нашем блоге, Durable Object, управляемый приложением Workers, действует как маршрутизатор сообщений, принимая изменения состояния игры от клиентов и сохраняя список активных клиентов, которые получают эти обновления через Worker.


Управление связями: прочный объект персонажа
Прежде чем приступить к этому проекту, я больше всего боялся работать с прочными объектами. Несмотря на то, что я никогда не создавал серьезных игр с Unity, и я не мог даже определить переменные C #, не выполнив поиск в Google по базовому синтаксису, кое-что в концептуальных частях Durable Objects продолжало пугать меня, вплоть до в тот момент, когда я начал писать реальный код.

Каково же было мое удивление, когда писать Durable Objects и работать с API оказалось невероятно легко.

Модуль Character, долговечный объект, использующий нашу новую поддержку модулей в Workers, построен на основе нашего шаблона modules-rollup-esm . Модуль обрабатывает входящие запросы и действует как поставщик WebSocket для клиентов:

export class Character {
  constructor(state, env) {
    this.state = state;
    this.env = env
  }

  async initialize() {
    let stored = await this.state.storage.get("state");
    this.value = stored || { users: [], websockets: [] }
  }

  async handleSession(websocket, ip) {
    websocket.accept()
    // Game state code
  }

  // Handle HTTP requests from clients.
  async fetch(request) {
    if (!this.initializePromise) {
      this.initializePromise = this.initialize().catch((err) => {
        this.initializePromise = undefined;
        throw err
      });
    }
    await this.initializePromise;

    // Apply requested action.
    let url = new URL(request.url);

    switch (url.pathname) {
      case "/websocket":
        if (request.headers.get("Upgrade") != "websocket") {
          return new Response("Expected websocket", { status: 406 })
        }
        let ip = request.headers.get("CF-Connecting-IP");
        let pair = new WebSocketPair();
        await this.handleSession(pair[1], ip);
        return new Response(null, { status: 101, webSocket: pair[0] });
      case "/":
        break;
      default:
        return new Response("Not found", { status: 404 });
    }

    return new Response(this.value);
  }
}
Большая часть этого концептуально идентична нашему шаблону websocket - мы ищем заголовок Upgrade во входящем запросе и настраиваем WebSocketPair, который содержит сервер и клиентский WebSocket.

В функции handleSession выполняется основная часть нашей игровой логики. В этом случае наш код Durable Objects + WebSocket выполняет две задачи: во-первых, обработка новых игроков - предоставление им случайно сгенерированного имени пользователя и установка для них действительного WebSocket, а во-вторых, принятие новых позиций игроков и трансляция этих позиций. всем, кто в данный момент участвует в игре. Функция `tick` используется для трансляции состояния игры нашим клиентам, а оставшаяся часть кода анализирует входящие данные и определяет, какие клиенты WebSocket должны получать новые данные. Код для этого показан ниже:

async tick(skipKey) {
  const users = this.value.users.filter(user => user.id !== skipKey)
  this.value.websockets
    .forEach(
      ({ id, name, websocket }) => {
        websocket.send(
          JSON.stringify({
            id,
            name,
            users
          })
        )
      }
    )
}

async key(ip) {
  const text = new TextEncoder().encode(`${this.env.SECRET}-${ip}`)
  const digest = await crypto.subtle.digest({ name: "SHA-256", }, text)
  const digestArray = new Uint8Array(digest)
  return btoa(String.fromCharCode.apply(null, digestArray))
}

constructName() {
  function titleCase(str) {
    return str.toLowerCase().split(' ').map(function (word) {
      return word.replace(word[0], word[0].toUpperCase());
    }).join(' ');
  }

  return titleCase(faker.fake("{{commerce.color}} {{hacker.adjective}} {{hacker.abbreviation}}"))
}

async handleSession(websocket, ip) {
  websocket.accept()

  try {
    let currentState = this.value;
    const key = await this.key(ip)

    const name = this.constructName()
    let newUser = { id: key, name, position: '0.0,0.0,0.0', rotation: '0.0,0.0,0.0' }
    if (!currentState.users.find(user => user.id === key)) {
      currentState.users.push(newUser)
      currentState.websockets.push({ id: key, name, websocket })
    }

    this.value = currentState
    this.tick(key)

    websocket.addEventListener("message", async msg => {
      try {
        let { type, position, rotation } = JSON.parse(msg.data)
        switch (type) {
          case 'POSITION_UPDATED':
            let user = currentState.users.find(user => user.id === key)
            if (user) {
              user.position = position
              user.rotation = rotation
            }

            this.value = currentState
            this.tick(key)

            break;
          default:
            console.log(`Unknown type of message ${type}`)
            websocket.send(JSON.stringify({ message: "UNKNOWN" }))
            break;
        }
      } catch (err) {
        websocket.send(JSON.stringify({ error: err.toString() }))
      }
    })

    const closeOrError = async evt => {
      currentState.users = currentState.users.filter(user => user.id !== key)
      currentState.websockets = currentState.websockets.filter(user => user.id !== key)
      this.value = currentState
      this.tick(key)
    }

    websocket.addEventListener("close", closeOrError)
    websocket.addEventListener("error", closeOrError)
  } catch (err) {
    websocket.send(JSON.stringify({ message: err.toString() }))
  }
}
При настройке новой WebSocketPair функция Workers создает уникальный идентификатор, полученный на основе IP-адреса пользователя (хотя вы можете так же легко использовать UUID или что-нибудь еще), и начинает отправлять данные WebSocket новому клиенту. Когда поступают данные (например, новая позиция игрока), функция проверяет, кто их отправляет, и отправляет новую информацию каждому другому WebSocket, находящемуся в данный момент в игре.

Управление положением и перемещением игрока: строительство с использованием долговечных объектов в Unity
Unity - отличный игровой движок для кого-то вроде меня: довольно опытного программиста, не имеющего опыта создания игр. Я работал с Unity в течение многих лет, но в последние несколько месяцев я глубоко погрузился в него и расширил свое понимание того, как на самом деле создавать настоящие игры.

Вот что вам нужно знать о Unity в контексте создания Durable World: игровые объекты - это основной класс всего в Unity, и с помощью сценариев C # вы можете запрограммировать различное поведение для своих игровых объектов, как сетевых, так и локальных для игрока.

В нашей игре есть три различных типа игровых объектов. Во-первых, это сам мир - набор статических мешей, в основном кубов. Эти сетки вообще не представлены в сетевых аспектах игры. Посредством серии коллайдеров любые другие игровые объекты, которые перемещаются поверх этих сеток или вокруг них, не могут проваливаться через пол и двигаться сквозь стены. Такой же дизайн вы видели в каждой 3D-игре за последние двадцать лет, включая такие классические игры, как Super Mario 64.


через GIPHY

В Durable World игровой объект вашего игрока представляет собой простую капсулу. Эта фигура встроена в Unity, и, прикрепив сценарий C #, мы можем выполнять базовое перемещение с помощью элементов управления с клавиатуры (в моем случае я использовал этот учебник от Brackey ).

Многопользовательские персонажи представлены в виде упрощенной версии одной и той же капсулы игрока. Вместо того, чтобы присоединять к этим игровым объектам какую-либо логику ввода (клавиатура, мышь и т. Д.), Ключевые аспекты их местоположения в трехмерном пространстве, а именно положение и вращение, управляются клиентом WebSocket.

Когда игра начинается, вы попадаете в однопользовательскую среду: ваш персонаж может перемещаться по статическому трехмерному миру. Как только игра подключается к Workers и получает WebSocket, она может начать действовать в многопользовательском контексте. Вот каркасный взгляд на мир до его запуска:


Когда дело доходит до фактического кода для проекта, аспекты подключения довольно просты: синглтон Connection создается при запуске игры, который использует класс WebSocket для подключения к Workers и вызова различных функций при новых обновлениях WebSocket. Вы можете найти полный код здесь , но я резюмирую важные части ниже.

Во-первых, нам нужно отправить позицию вашего игрока обратно в Workers. Это происходит в цикле, вызываемом каждые 0,2 секунды. Функция UpdatePosition принимает положение и поворот игрока, кодирует их в JSON и отправляет данные в WebSocket. Обратите внимание, что, отправляя позицию каждые 0,2 секунды, мы эффективно создаем плеер, который обновляется со скоростью пять кадров в секунду. Учитывая, что в большинстве игр частота кадров составляет не менее 30 кадров в секунду, если не больше, эту проблему мы решим позже с помощью интерполяции.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using NativeWebSocket;

public class Connection : MonoBehaviour
{
  WebSocket websocket;

  // Start is called before the first frame update
  void Start()
  {
    Connect();
  }

  async void Connect()
  {
    retries += 1;

    if (maxRetries < retries)
    {
      return;
    }

    websocket = new WebSocket("wss://durable-world.signalnerve.workers.dev/websocket");

    websocket.OnOpen += () =>
    {
      Debug.Log("Connection open!");
    };

    websocket.OnError += (e) =>
    {
      Debug.Log("Error! " + e);
      Connect();
    };

    websocket.OnClose += (e) =>
    {
      Debug.Log("Connection closed!" + e);
      Connect();
    };

    websocket.OnMessage += (bytes) =>
    {
      // Do things with new messages
    };

    // Keep sending messages at every 0.2 seconds
    InvokeRepeating("UpdatePosition", 0.0f, 0.2f);

    // waiting for messages
    await websocket.Connect();
  }

  void Update()
  {
#if !UNITY_WEBGL || UNITY_EDITOR
    websocket.DispatchMessageQueue();
#endif
  }

  async void UpdatePosition()
  {
    if (websocket.State == WebSocketState.Open)
    {
      var currentPos = player.transform.position;
      if (currentPos == lastPosition)
      {
        return;
      }

      PlayerPosition playerPosition = new PlayerPosition();
      playerPosition.position = $"{currentPos.x},{currentPos.y},{currentPos.z}";
      var currentRot = player.transform.rotation;
      playerPosition.rotation = $"{currentRot.eulerAngles.x},{currentRot.eulerAngles.y},{currentRot.eulerAngles.z}";
      playerPosition.type = "POSITION_UPDATED";
      await websocket.SendText(JsonUtility.ToJson(playerPosition));
      lastPosition = currentPos;
    }
  }

  private async void OnApplicationQuit()
  {
    await websocket.Close();
  }
}
Затем нам нужно прислушаться к другим игрокам, которые в данный момент находятся в игре. Чтобы справиться с этим, мы прослушиваем входящие сообщения WebSocket от Workers. Каждое сообщение будет содержать полное состояние нашей игры (то, что мы определенно можем оптимизировать в будущем), которое мы можем анализировать и использовать для принятия решений о том, как должна обновляться наша локальная версия игры. Для каждого пользователя в нашей полезной нагрузке gameState мы можем создать новый экземпляр игрока и начать отслеживать его локально. Мы также можем обновить положение, поворот и установить простой элемент пользовательского интерфейса, указывающий имя игрока, внутри CreateClient:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using NativeWebSocket;

public class Connection : MonoBehaviour
{
  async void Connect()
  {
    // Truncated code

    websocket.OnMessage += (bytes) =>
    {
      var payload = System.Text.Encoding.UTF8.GetString(bytes);
      GameState gameState = JsonUtility.FromJson<GameState>(payload);

      foreach (var user in gameState.users)
      {
        try
        {
          if (user.id == gameState.id)
          {
            continue;
          }

          Client client;
          if (!Clients.TryGetValue(user.id, out client))
          {
            client = CreateClient(user);
          }

          var rt = user.rotation.Split(","[0]); // gets 3 parts of the vector into separate strings
          var rtx = float.Parse(rt[0]);
          var rty = float.Parse(rt[1]);
          var rtz = float.Parse(rt[2]);
          var newRot = Quaternion.Euler(rtx, rty, rtz);
          client.interpolateMovement.endRotation = newRot;

          var pt = user.position.Split(","[0]); // gets 3 parts of the vector into separate strings
          var ptx = float.Parse(pt[0]);
          var pty = float.Parse(pt[1]);
          var ptz = float.Parse(pt[2]);
          var newPos = new Vector3(ptx, pty, ptz);
          client.interpolateMovement.endPosition = newPos;
        }
        catch (Exception e)
        {
          Debug.Log(e);
        }
      }

      TMPro.TextMeshProUGUI text = onlineText.GetComponent<TMPro.TextMeshProUGUI>();
      text.text = $"Online: {gameState.users.Length + 1}\\nPlaying as {gameState.name}";
    };

    // Keep sending messages at every 0.2 seconds
    InvokeRepeating("UpdatePosition", 0.0f, 0.2f);

    // waiting for messages
    await websocket.Connect();
  }

  Client CreateClient(User user)
  {
    var newClient = new Client();
    newClient.id = user.id;
    var otherPlayer = Instantiate(otherPlayerPrefab, new Vector3(0, 0, 0), Quaternion.identity);
    otherPlayer.name = user.id;

    TMPro.TextMeshPro text = otherPlayer.GetComponentInChildren<TMPro.TextMeshPro>();
    text.text = user.name;

    newClient.playerObject = otherPlayer;
    newClient.interpolateMovement = otherPlayer.GetComponent<InterpolateMovement>();
    Clients.Add(user.id, newClient);
    return newClient;
  }

  // Truncated code
}
Настроив весь этот код, мы создали простую систему для отправки нашей позиции локального игрока Рабочим. Когда моя позиция игрока обновляется, все остальные участники игры получают позицию как часть более крупной полезной нагрузки состояния игры и соответственно обновляют локальную копию каждого игрока.

Я упоминал, что эти обновления происходят каждые 0,2 секунды . Ожидается, что игры будут обновляться как минимум тридцать раз в секунду, если не больше: современные игры обычно работают со скоростью 60 кадров в секунду и обновляются очень быстро.

Именно из-за этого ожидания нам нужно интерполировать движения для наших игроков. Вместо того, чтобы посылать обновления игроку шестьдесят раз в секунду, что было бы огромной нагрузкой на наш прочный объект, мы можем посмотреть на входящую новую позицию или поворот объекта и использовать некоторые математические вычисления, чтобы сгладить движение от того места, где находится игрок , к месту. они идут . Unity (и многие другие игровые движки) обеспечивают такое поведение через API, такие как SmoothDamp - функция для сглаживания быстрого резкого движения с течением времени - как показано ниже в скрипте InterpolateMovement, который используется для управления положением и вращением игрока:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InterpolateMovement : MonoBehaviour
{
  public Vector3 endPosition;
  public Quaternion endRotation;

  public float rotationSmoothTime = 0.3f;
  public float positionSmoothTime = 0.6f;
  private Vector3 posVelocity = Vector3.zero;
  private float rotVelocity = 0.0f;

  void Update()
  {
    transform.position = Vector3.SmoothDamp(transform.position, endPosition, ref posVelocity, positionSmoothTime);

    float delta = Quaternion.Angle(transform.rotation, endRotation);
    if (delta > 0f)
    {
      float t = Mathf.SmoothDampAngle(delta, 0.0f, ref rotVelocity, rotationSmoothTime);
      t = 1.0f - (t / delta);
      transform.rotation = Quaternion.Slerp(transform.rotation, endRotation, t);
    }
  }
}
Что дальше
Доступность таких инструментов, как Durable Objects и WebSockets на периферии, открывает новый класс приложений, которые мы можем создавать с помощью Cloudflare Workers. Игры - это всего лишь единичный вариант использования, и мы только начали изучать возможности интерактивных игр в реальном времени на периферии. Если вам интересно ознакомиться с исходным кодом Durable World, вы можете проверить его на GitHub . Присоединяйтесь к нам в Discord Cloudflare Workers, если вы хотите поговорить с Workers, Durable Objects или чем-нибудь еще, исследуя новые интересные вещи, которые мы можем создать в распределенном бессерверном контексте.

Комментариев нет:

Отправить комментарий